feat: admin panel overhaul - editor post selector, changelog/sitrep/datasync/cronjobs sections, backup import, grouped sidebar

This commit is contained in:
jae 2026-04-06 20:02:29 +00:00
parent 3d01e1e173
commit bb2aecd9b8
3 changed files with 890 additions and 19 deletions

View file

@ -56,6 +56,7 @@
<button class="sidebar-close" onclick="AdminApp.toggleSidebar()" aria-label="Close menu"></button>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section-label">CONTENT</div>
<a href="javascript:void(0)" class="sidebar-link active" data-section="dashboard" onclick="event.preventDefault(); AdminApp.showSection('dashboard')">
<span class="sidebar-icon"></span> Dashboard
</a>
@ -65,16 +66,33 @@
<a href="javascript:void(0)" class="sidebar-link" data-section="editor" onclick="event.preventDefault(); AdminApp.showSection('editor')">
<span class="sidebar-icon"></span> Editor
</a>
<a href="javascript:void(0)" class="sidebar-link" data-section="changelog" onclick="event.preventDefault(); AdminApp.showSection('changelog')">
<span class="sidebar-icon">📋</span> Changelog
</a>
<a href="javascript:void(0)" class="sidebar-link" data-section="tracks" onclick="event.preventDefault(); AdminApp.showSection('tracks')">
<span class="sidebar-icon"></span> Tracks
</a>
<a href="javascript:void(0)" class="sidebar-link" data-section="settings" onclick="event.preventDefault(); AdminApp.showSection('settings')">
<span class="sidebar-icon"></span> Settings
<div class="sidebar-divider"></div>
<div class="sidebar-section-label">AUTOMATION</div>
<a href="javascript:void(0)" class="sidebar-link" data-section="sitrep" onclick="event.preventDefault(); AdminApp.showSection('sitrep')">
<span class="sidebar-icon">📡</span> SITREP
</a>
<a href="javascript:void(0)" class="sidebar-link" data-section="datasync" onclick="event.preventDefault(); AdminApp.showSection('datasync')">
<span class="sidebar-icon"></span> Data Sync
</a>
<a href="javascript:void(0)" class="sidebar-link" data-section="cronjobs" onclick="event.preventDefault(); AdminApp.showSection('cronjobs')">
<span class="sidebar-icon"></span> Cron Jobs
</a>
<a href="javascript:void(0)" class="sidebar-link" data-section="radar" onclick="event.preventDefault(); window.open('/transmissions/radar', '_blank')">
<span class="sidebar-icon">📻</span> RADAR ↗
</a>
<div class="sidebar-divider"></div>
<div class="sidebar-section-label">CONTENT</div>
<div class="sidebar-section-label">SITE CONFIG</div>
<a href="javascript:void(0)" class="sidebar-link" data-section="settings" onclick="event.preventDefault(); AdminApp.showSection('settings')">
<span class="sidebar-icon"></span> Settings
</a>
<a href="javascript:void(0)" class="sidebar-link" data-section="homepage" onclick="event.preventDefault(); AdminApp.showSection('homepage')">
<span class="sidebar-icon"></span> Homepage
</a>
@ -91,10 +109,8 @@
<span class="sidebar-icon">🤖</span> Chat AI
</a>
<div class="sidebar-divider"></div>
<div class="sidebar-section-label">SYSTEM</div>
<a href="javascript:void(0)" class="sidebar-link" data-section="services" onclick="event.preventDefault(); AdminApp.showSection('services')">
<span class="sidebar-icon"></span> Services
</a>
@ -115,7 +131,7 @@
</a>
</nav>
<div class="sidebar-footer">
<span class="sidebar-version">v2.0 // EXPANDED HUD</span>
<span class="sidebar-version">v3.0 // FULL HUD</span>
</div>
</div>
@ -206,6 +222,15 @@
<div id="section-editor" class="admin-section">
<h2 class="section-heading">✎ POST EDITOR</h2>
<!-- Post Selector -->
<div class="section-toolbar" style="margin-bottom:1.5rem;">
<select id="editorPostSelect" class="editor-input" style="max-width:400px;" onchange="AdminApp.loadPostForEdit(this.value)">
<option value="">— NEW POST —</option>
</select>
<button class="action-btn accent" onclick="AdminApp.clearEditor()"> NEW POST</button>
<button class="action-btn danger" id="editorDeleteBtn" onclick="AdminApp.deletePost(document.getElementById('editorPostId').value)" style="display:none;">✕ DELETE</button>
</div>
<!-- Post Metadata -->
<div class="editor-row">
<div class="editor-field">
@ -1008,10 +1033,9 @@
<!-- 14. BACKUPS -->
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
<div id="section-backups" class="admin-section">
<h2 class="section-heading">⛋ BACKUPS & EXPORT</h2>
<h2 class="section-heading">⛋ BACKUPS & DATA</h2>
<div class="backup-grid">
<!-- Individual Downloads -->
<div class="backup-card">
<div class="backup-card-icon"></div>
<div class="backup-card-title">POSTS DATA</div>
@ -1035,11 +1059,22 @@
<!-- Export All -->
<div class="backup-export">
<h3 class="section-subheading">⛋ FULL EXPORT</h3>
<p class="backup-desc">Download all site data (posts, tracks, settings) as a single ZIP archive.</p>
<p class="backup-desc">Download all site data as a single ZIP archive.</p>
<button class="action-btn accent lg" onclick="AdminApp.downloadBackup('all')">⬡ EXPORT ALL DATA (ZIP)</button>
</div>
<!-- Backup Status -->
<!-- Import -->
<div class="backup-export" style="margin-top:2rem;">
<h3 class="section-subheading">⬡ IMPORT / RESTORE</h3>
<p class="backup-desc">Upload a previously exported ZIP to restore all JSON data files.</p>
<div class="editor-row" style="margin-top:1rem;">
<div class="editor-field">
<input type="file" id="backupImportFile" class="editor-input" accept=".zip">
</div>
</div>
<button class="action-btn warning lg" onclick="AdminApp.importBackup()" style="margin-top:0.5rem;">⬡ IMPORT BACKUP (ZIP)</button>
</div>
<div id="backupStatus" class="backup-status"></div>
</div>
@ -1181,6 +1216,155 @@
</div>
</div>
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
<!-- 17. CHANGELOG -->
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
<div id="section-changelog" class="admin-section">
<h2 class="section-heading">📋 CHANGELOG</h2>
<!-- Add Entry Form -->
<div class="admin-card" style="margin-bottom:2rem;">
<h3 class="section-subheading"> ADD ENTRY</h3>
<div class="editor-row">
<div class="editor-field">
<label class="editor-label" for="clVersion">VERSION</label>
<input type="text" id="clVersion" class="editor-input" placeholder="v3.0.1">
</div>
<div class="editor-field">
<label class="editor-label" for="clDate">DATE</label>
<input type="date" id="clDate" class="editor-input">
</div>
<div class="editor-field">
<label class="editor-label" for="clType">TYPE</label>
<select id="clType" class="editor-input">
<option value="update">UPDATE</option>
<option value="feature">FEATURE</option>
<option value="fix">FIX</option>
<option value="security">SECURITY</option>
<option value="breaking">BREAKING</option>
</select>
</div>
</div>
<div class="editor-row">
<div class="editor-field full">
<label class="editor-label" for="clTitle">TITLE</label>
<input type="text" id="clTitle" class="editor-input" placeholder="Entry title...">
</div>
</div>
<div class="editor-row">
<div class="editor-field full">
<label class="editor-label" for="clDescription">DESCRIPTION</label>
<textarea id="clDescription" class="editor-input editor-textarea-sm" placeholder="Brief description..."></textarea>
</div>
</div>
<div class="editor-row">
<div class="editor-field full">
<label class="editor-label" for="clChanges">CHANGES (one per line)</label>
<textarea id="clChanges" class="editor-input editor-textarea-sm" placeholder="Added new feature X&#10;Fixed bug in Y&#10;Improved Z performance"></textarea>
</div>
</div>
<input type="hidden" id="clEditId" value="">
<button class="action-btn accent" onclick="AdminApp.saveChangelog()">⬡ SAVE ENTRY</button>
<button class="action-btn" onclick="AdminApp.clearChangelog()" style="margin-left:0.5rem;">✕ CLEAR</button>
</div>
<!-- Entries List -->
<div id="changelogList" class="changelog-admin-list">
<!-- Populated by JS -->
</div>
</div>
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
<!-- 18. SITREP -->
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
<div id="section-sitrep" class="admin-section">
<h2 class="section-heading">📡 SITREP — DAILY BRIEFING</h2>
<!-- Stats -->
<div class="dash-grid" style="margin-bottom:2rem;">
<div class="dash-card">
<div class="dash-card-label">TOTAL REPORTS</div>
<div class="dash-card-value" id="sitrepTotal">--</div>
</div>
<div class="dash-card">
<div class="dash-card-label">LATEST DATE</div>
<div class="dash-card-value" id="sitrepLatestDate">--</div>
</div>
<div class="dash-card">
<div class="dash-card-label">STATUS</div>
<div class="dash-card-value" id="sitrepStatus">--</div>
</div>
</div>
<!-- Generate Button -->
<div class="section-toolbar">
<button class="action-btn warning lg" onclick="AdminApp.generateSitrep()" id="sitrepGenerateBtn">⚡ GENERATE NOW</button>
</div>
<!-- Today's SITREP Summary -->
<div id="sitrepTodaySummary" class="admin-card" style="margin-top:1.5rem; display:none;">
<h3 class="section-subheading">⬡ TODAY'S SITREP</h3>
<div id="sitrepHeadline" class="sitrep-headline"></div>
<div id="sitrepSummaryText" class="sitrep-summary-text"></div>
</div>
<!-- Archive List -->
<h3 class="section-subheading" style="margin-top:2rem;">⛋ ARCHIVE</h3>
<div id="sitrepArchive" class="sitrep-archive-list">
<!-- Populated by JS -->
</div>
</div>
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
<!-- 19. DATA SYNC -->
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
<div id="section-datasync" class="admin-section">
<h2 class="section-heading">⟳ DATA SYNC</h2>
<div class="sync-grid">
<!-- Contraband Card -->
<div class="admin-card sync-card">
<h3 class="section-subheading">🚫 CONTRABAND (FMHY)</h3>
<div class="sync-stats">
<div class="sync-stat"><span class="sync-stat-label">ENTRIES:</span> <span id="syncCbEntries">--</span></div>
<div class="sync-stat"><span class="sync-stat-label">CATEGORIES:</span> <span id="syncCbCategories">--</span></div>
<div class="sync-stat"><span class="sync-stat-label">STARRED:</span> <span id="syncCbStarred">--</span></div>
<div class="sync-stat"><span class="sync-stat-label">LAST SYNC:</span> <span id="syncCbLastSync">--</span></div>
</div>
<button class="action-btn warning" onclick="AdminApp.runSync('contraband')" id="syncCbBtn">⚡ SYNC NOW</button>
</div>
<!-- RECON Card -->
<div class="admin-card sync-card">
<h3 class="section-subheading">🔍 RECON (AWESOMELIST)</h3>
<div class="sync-stats">
<div class="sync-stat"><span class="sync-stat-label">SECTORS:</span> <span id="syncAlSectors">--</span></div>
<div class="sync-stat"><span class="sync-stat-label">LISTS:</span> <span id="syncAlLists">--</span></div>
<div class="sync-stat"><span class="sync-stat-label">ENTRIES:</span> <span id="syncAlEntries">--</span></div>
<div class="sync-stat"><span class="sync-stat-label">LAST SYNC:</span> <span id="syncAlLastSync">--</span></div>
</div>
<button class="action-btn warning" onclick="AdminApp.runSync('awesomelist')" id="syncAlBtn">⚡ SYNC NOW</button>
</div>
</div>
<div id="syncLog" class="sync-log" style="margin-top:1.5rem; display:none;">
<h3 class="section-subheading">📋 SYNC LOG</h3>
<pre id="syncLogContent" class="sync-log-content"></pre>
</div>
</div>
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
<!-- 20. CRON JOBS -->
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
<div id="section-cronjobs" class="admin-section">
<h2 class="section-heading">⏱ CRON JOBS</h2>
<div id="cronJobsList" class="cron-jobs-list">
<!-- Populated by JS -->
</div>
</div>
</div><!-- /main-content -->
</div><!-- /adminApp -->

View file

@ -24,7 +24,7 @@ ADMIN_PASS = 'HUDAdmin2026!'
ARRAY_FILES = {
'posts.json', 'tracks.json', 'navigation.json', 'links.json',
'managed_services.json', 'messages.json'
'managed_services.json', 'messages.json', 'changelog.json'
}
# ─── JSON Error Handlers ─────────────────────────────
@app.errorhandler(400)
@ -898,14 +898,252 @@ def contraband_search():
# ─── Run ─────────────────────────────────────────────
# ─── Changelog ────────────────────────────────────────
# ─── Changelog CRUD ───────────────────────────────────
@app.route('/api/changelog')
def get_changelog():
return jsonify(load_json('changelog.json'))
@app.route('/api/changelog', methods=['POST'])
@require_auth
def add_changelog_entry():
try:
with open(os.path.join(DATA_DIR, 'changelog.json'), 'r') as f:
return json.load(f)
d = request.get_json(force=True)
entries = load_json('changelog.json')
if not isinstance(entries, list):
entries = []
entry = {
'id': max((e.get('id', 0) for e in entries), default=0) + 1,
'version': d.get('version', ''),
'date': d.get('date', datetime.date.today().isoformat()),
'title': d.get('title', ''),
'description': d.get('description', ''),
'changes': d.get('changes', []),
'type': d.get('type', 'update')
}
entries.insert(0, entry)
save_json('changelog.json', entries)
return jsonify(entry), 201
except Exception as e:
return {"error": str(e)}, 500
return jsonify({'error': str(e)}), 500
@app.route('/api/changelog/<int:entry_id>', methods=['PUT'])
@require_auth
def update_changelog_entry(entry_id):
try:
d = request.get_json(force=True)
entries = load_json('changelog.json')
for i, e in enumerate(entries):
if e.get('id') == entry_id:
entries[i] = {**e, **d, 'id': entry_id}
save_json('changelog.json', entries)
return jsonify(entries[i])
abort(404, 'Changelog entry not found')
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/changelog/<int:entry_id>', methods=['DELETE'])
@require_auth
def delete_changelog_entry(entry_id):
entries = load_json('changelog.json')
entries = [e for e in entries if e.get('id') != entry_id]
save_json('changelog.json', entries)
return jsonify({'ok': True})
# ─── Data Sync (Contraband + Awesomelist) ─────────────
@app.route('/api/sync/status')
@require_auth
def sync_status():
"""Return last sync times and stats for contraband and awesomelist."""
result = {}
# Contraband stats
cb_path = DATA_DIR / 'contraband.json'
if cb_path.exists():
cb_data = _load_contraband()
result['contraband'] = {
'last_sync': datetime.datetime.fromtimestamp(cb_path.stat().st_mtime).isoformat(),
'total_entries': cb_data.get('total_entries', 0),
'total_categories': cb_data.get('total_categories', 0),
'total_starred': cb_data.get('total_starred', 0),
'file_size': cb_path.stat().st_size
}
else:
result['contraband'] = {'last_sync': None, 'total_entries': 0, 'total_categories': 0, 'total_starred': 0}
# Awesomelist stats
al_index = DATA_DIR / 'awesomelist_index.json'
al_dir = DATA_DIR / 'awesomelist'
if al_index.exists():
al_data = _load_awesomelist_index() or {}
sector_count = len(list(al_dir.glob('sector_*.json'))) if al_dir.exists() else 0
result['awesomelist'] = {
'last_sync': datetime.datetime.fromtimestamp(al_index.stat().st_mtime).isoformat(),
'total_sectors': al_data.get('total_sectors', sector_count),
'total_lists': al_data.get('total_lists', 0),
'total_entries': al_data.get('total_entries', 0),
}
else:
result['awesomelist'] = {'last_sync': None, 'total_sectors': 0, 'total_lists': 0, 'total_entries': 0}
return jsonify(result)
@app.route('/api/sync/contraband', methods=['POST'])
@require_auth
def sync_contraband():
"""Trigger contraband sync."""
try:
script = Path(__file__).parent / 'contraband_sync.py'
if not script.exists():
return jsonify({'error': 'contraband_sync.py not found'}), 500
result = subprocess.run(
['python3', str(script)],
capture_output=True, text=True, timeout=300,
cwd=str(script.parent)
)
if result.returncode == 0:
return jsonify({'status': 'ok', 'message': 'Contraband sync completed', 'log': result.stdout[-500:] if result.stdout else ''})
else:
return jsonify({'status': 'error', 'message': 'Sync failed', 'stderr': result.stderr[-500:] if result.stderr else ''}), 500
except subprocess.TimeoutExpired:
return jsonify({'status': 'error', 'message': 'Sync timed out (300s)'}), 504
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/api/sync/awesomelist', methods=['POST'])
@require_auth
def sync_awesomelist():
"""Trigger awesomelist sync."""
try:
script = Path(__file__).parent / 'awesomelist_sync.py'
if not script.exists():
return jsonify({'error': 'awesomelist_sync.py not found'}), 500
result = subprocess.run(
['python3', str(script)],
capture_output=True, text=True, timeout=600,
cwd=str(script.parent)
)
if result.returncode == 0:
return jsonify({'status': 'ok', 'message': 'Awesomelist sync completed', 'log': result.stdout[-500:] if result.stdout else ''})
else:
return jsonify({'status': 'error', 'message': 'Sync failed', 'stderr': result.stderr[-500:] if result.stderr else ''}), 500
except subprocess.TimeoutExpired:
return jsonify({'status': 'error', 'message': 'Sync timed out (600s)'}), 504
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
# ─── Cron Jobs Management ─────────────────────────────
CRON_JOBS = [
{'id': 'contraband', 'name': 'Contraband Sync', 'pattern': 'contraband_sync', 'schedule_default': '0 3 * * 0'},
{'id': 'awesomelist', 'name': 'Awesomelist Sync', 'pattern': 'awesomelist_sync', 'schedule_default': '0 4 * * *'},
{'id': 'sitrep', 'name': 'SITREP Generator', 'pattern': 'sitrep_generator', 'schedule_default': '0 7 * * *'},
]
@app.route('/api/crons')
@require_auth
def get_crons():
"""Parse crontab and return status of known cron jobs."""
try:
raw = subprocess.run(['crontab', '-l'], capture_output=True, text=True, timeout=5)
crontab_lines = raw.stdout.strip().split('\n') if raw.returncode == 0 else []
except Exception:
crontab_lines = []
results = []
for job in CRON_JOBS:
found = False
enabled = False
schedule = job['schedule_default']
full_line = ''
for line in crontab_lines:
if job['pattern'] in line:
found = True
full_line = line
stripped = line.lstrip()
enabled = not stripped.startswith('#')
# Parse schedule from the line
parts = stripped.lstrip('#').strip().split(None, 5)
if len(parts) >= 5:
schedule = ' '.join(parts[:5])
break
results.append({
'id': job['id'],
'name': job['name'],
'found': found,
'enabled': enabled,
'schedule': schedule,
'full_line': full_line
})
return jsonify(results)
@app.route('/api/crons/<job_id>/toggle', methods=['POST'])
@require_auth
def toggle_cron(job_id):
"""Enable or disable a cron job by commenting/uncommenting its line."""
job = next((j for j in CRON_JOBS if j['id'] == job_id), None)
if not job:
return jsonify({'error': 'Unknown cron job'}), 404
try:
raw = subprocess.run(['crontab', '-l'], capture_output=True, text=True, timeout=5)
if raw.returncode != 0:
return jsonify({'error': 'Cannot read crontab'}), 500
lines = raw.stdout.strip().split('\n')
new_lines = []
toggled = False
for line in lines:
if job['pattern'] in line:
stripped = line.lstrip()
if stripped.startswith('#'):
# Enable it - remove leading #
new_lines.append(stripped.lstrip('#').strip())
else:
# Disable it - add #
new_lines.append('# ' + line)
toggled = True
else:
new_lines.append(line)
if not toggled:
return jsonify({'error': 'Cron job line not found in crontab'}), 404
# Write new crontab
new_crontab = '\n'.join(new_lines) + '\n'
proc = subprocess.run(['crontab', '-'], input=new_crontab, capture_output=True, text=True, timeout=5)
if proc.returncode != 0:
return jsonify({'error': 'Failed to write crontab: ' + proc.stderr}), 500
return jsonify({'ok': True, 'job_id': job_id})
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── Backup Import ────────────────────────────────────
@app.route('/api/backups/import', methods=['POST'])
@require_auth
def backup_import():
"""Accept a ZIP file and restore JSON data files from it."""
try:
if 'file' not in request.files:
return jsonify({'error': 'No file uploaded'}), 400
f = request.files['file']
if not f.filename.endswith('.zip'):
return jsonify({'error': 'Only ZIP files accepted'}), 400
buf = io.BytesIO(f.read())
restored = []
with zipfile.ZipFile(buf, 'r') as zf:
for name in zf.namelist():
if name.endswith('.json') and '/' not in name:
# Only restore top-level JSON files
target = DATA_DIR / name
target.write_bytes(zf.read(name))
restored.append(name)
return jsonify({'ok': True, 'restored': restored, 'count': len(restored)})
except zipfile.BadZipFile:
return jsonify({'error': 'Invalid ZIP file'}), 400
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── Awesome Lists ────────────────────────────────────

View file

@ -235,7 +235,11 @@ const AdminApp = {
'section-dashboard': 'Dashboard',
'section-posts': 'Posts',
'section-editor': 'Editor',
'section-changelog': 'Changelog',
'section-tracks': 'Tracks',
'section-sitrep': 'SITREP',
'section-datasync': 'Data Sync',
'section-cronjobs': 'Cron Jobs',
'section-settings': 'Settings',
'section-homepage': 'Homepage',
'section-services': 'Services',
@ -256,6 +260,7 @@ const AdminApp = {
switch (name) {
case 'section-dashboard': this.loadDashboard(); break;
case 'section-posts': this.loadPosts(); break;
case 'section-editor': this.loadEditorPostList(); break;
case 'section-tracks': this.loadTracks(); break;
case 'section-settings': this.loadSettings(); break;
case 'section-homepage': this.loadHomepage(); break;
@ -268,6 +273,10 @@ const AdminApp = {
case 'section-contact': this.loadContactSettings(); break;
case 'section-globe': this.loadGlobe(); break;
case 'section-chatai': this.loadChatAI(); break;
case 'section-changelog': this.loadChangelog(); break;
case 'section-sitrep': this.loadSitrep(); break;
case 'section-datasync': this.loadDataSync(); break;
case 'section-cronjobs': this.loadCronJobs(); break;
}
// Close mobile sidebar
@ -638,6 +647,12 @@ const AdminApp = {
const preview = document.getElementById('editorPreview');
if (preview) preview.innerHTML = '';
// Reset post selector
const sel = document.getElementById('editorPostSelect');
if (sel) sel.value = '';
const delBtn = document.getElementById('editorDeleteBtn');
if (delBtn) delBtn.style.display = 'none';
// Reset HUD
const moodEl = document.getElementById('hudMood');
if (moodEl) moodEl.value = 'focused';
@ -658,9 +673,6 @@ const AdminApp = {
if (bpmEl) bpmEl.value = 80;
const threatEl = document.getElementById('hudThreat');
if (threatEl) threatEl.value = 'low';
const heading = document.getElementById('editorTitle');
if (heading) heading.textContent = 'NEW POST';
},
/* ─────────────────── EDITOR TOOLBAR ─────────────────── */
@ -1763,6 +1775,443 @@ const AdminApp = {
const el = document.getElementById(id);
return el ? el.checked : false;
}
/* ─────────────────── EDITOR POST LIST ─────────────────── */
async loadEditorPostList() {
try {
const res = await fetch(this.API + '/posts', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load posts');
const data = await res.json();
const posts = Array.isArray(data) ? data : (data.posts || []);
posts.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
const sel = document.getElementById('editorPostSelect');
if (!sel) return;
const currentVal = sel.value;
sel.innerHTML = '<option value="">— NEW POST —</option>';
posts.forEach(p => {
const opt = document.createElement('option');
opt.value = p.slug;
opt.textContent = `${p.title || 'Untitled'} (${p.slug})`;
sel.appendChild(opt);
});
if (currentVal) sel.value = currentVal;
} catch (err) {
console.error('Editor post list error:', err);
}
},
async loadPostForEdit(slug) {
if (!slug) {
this.clearEditor();
return;
}
try {
const res = await fetch(this.API + '/posts/' + encodeURIComponent(slug), { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load post');
const post = await res.json();
document.getElementById('editorPostId').value = post.slug || slug;
document.getElementById('editorTitle').value = post.title || '';
document.getElementById('editorSlug').value = post.slug || '';
if (post.date) {
const dt = new Date(post.date);
document.getElementById('editorDate').value = dt.toISOString().split('T')[0];
document.getElementById('editorTime').value = dt.toTimeString().slice(0, 5);
}
document.getElementById('editorTags').value = Array.isArray(post.tags) ? post.tags.join(', ') : (post.tags || '');
document.getElementById('editorExcerpt').value = post.excerpt || '';
document.getElementById('editorContent').value = post.content || '';
// Operator HUD
const moodEl = document.getElementById('hudMood');
if (moodEl) moodEl.value = post.mood || 'focused';
const hudMap = { hudEnergy: 'energy', hudMotivation: 'motivation', hudFocus: 'focus', hudDifficulty: 'difficulty' };
Object.entries(hudMap).forEach(([id, key]) => {
const el = document.getElementById(id);
if (el) {
el.value = post[key] || 3;
const valEl = document.getElementById(id + 'Val');
if (valEl) valEl.textContent = el.value;
}
});
const coffeeEl = document.getElementById('hudCoffee');
if (coffeeEl) { coffeeEl.value = post.coffee || 0; const v = document.getElementById('hudCoffeeVal'); if (v) v.textContent = coffeeEl.value; }
const bpmEl = document.getElementById('hudBPM');
if (bpmEl) { bpmEl.value = post.bpm || post.heart_rate || 72; const v = document.getElementById('hudBPMVal'); if (v) v.textContent = bpmEl.value; }
const threatEl = document.getElementById('hudThreat');
if (threatEl) threatEl.value = post.threat_level || 'low';
// Show delete button
const delBtn = document.getElementById('editorDeleteBtn');
if (delBtn) delBtn.style.display = 'inline-block';
this.updatePreview();
this.notify('Post loaded: ' + (post.title || slug));
} catch (err) {
console.error('Load post for edit error:', err);
this.notify('Failed to load post', 'error');
}
},
/* ─────────────────── CHANGELOG ─────────────────── */
async loadChangelog() {
try {
const res = await fetch(this.API + '/changelog', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load changelog');
const entries = await res.json();
const list = document.getElementById('changelogList');
if (!list) return;
if (!Array.isArray(entries) || entries.length === 0) {
list.innerHTML = '<div style="color:#555;text-align:center;padding:2rem;">No changelog entries yet</div>';
return;
}
const typeColors = { feature: '#00ffc8', fix: '#ffcc00', security: '#ff4444', breaking: '#ff0066', update: '#888' };
list.innerHTML = entries.map(e => {
const changes = Array.isArray(e.changes) ? e.changes : [];
const typeColor = typeColors[e.type] || '#888';
return `<div class="admin-card" style="margin-bottom:1rem;border-left:3px solid ${typeColor};">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<strong style="color:${typeColor};">[${(e.type || 'update').toUpperCase()}]</strong>
<strong>${this.escapeHtml(e.version || '')}</strong> ${this.escapeHtml(e.title || '')}
<span style="color:#555;margin-left:1rem;">${e.date || ''}</span>
</div>
<div>
<button class="btn-sm btn-edit" onclick="AdminApp.editChangelog(${e.id})" title="Edit"></button>
<button class="btn-sm btn-delete" onclick="AdminApp.deleteChangelog(${e.id})" title="Delete"></button>
</div>
</div>
${e.description ? `<p style="color:#aaa;margin:0.5rem 0 0;">${this.escapeHtml(e.description)}</p>` : ''}
${changes.length ? `<ul style="margin:0.5rem 0 0;padding-left:1.2rem;color:#888;">${changes.map(c => `<li>${this.escapeHtml(c)}</li>`).join('')}</ul>` : ''}
</div>`;
}).join('');
} catch (err) {
console.error('Changelog load error:', err);
this.notify('Failed to load changelog', 'error');
}
},
async saveChangelog() {
const editId = document.getElementById('clEditId').value;
const version = this.getVal('clVersion');
const date = this.getVal('clDate') || new Date().toISOString().split('T')[0];
const type = this.getVal('clType') || 'update';
const title = this.getVal('clTitle');
const description = this.getVal('clDescription');
const changesRaw = this.getVal('clChanges');
const changes = changesRaw.split('\n').map(c => c.trim()).filter(c => c);
if (!title) { this.notify('Title is required', 'error'); return; }
const payload = { version, date, type, title, description, changes };
try {
let url, method;
if (editId) {
url = this.API + '/changelog/' + editId;
method = 'PUT';
} else {
url = this.API + '/changelog';
method = 'POST';
}
const res = await fetch(url, { method, headers: this.authHeaders(), body: JSON.stringify(payload) });
if (!res.ok) throw new Error('Save failed');
this.notify(editId ? 'Entry updated' : 'Entry added');
this.clearChangelog();
this.loadChangelog();
} catch (err) {
console.error('Changelog save error:', err);
this.notify('Failed to save changelog entry', 'error');
}
},
clearChangelog() {
document.getElementById('clEditId').value = '';
document.getElementById('clVersion').value = '';
document.getElementById('clDate').value = '';
document.getElementById('clType').value = 'update';
document.getElementById('clTitle').value = '';
document.getElementById('clDescription').value = '';
document.getElementById('clChanges').value = '';
},
async editChangelog(id) {
try {
const res = await fetch(this.API + '/changelog', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load');
const entries = await res.json();
const entry = entries.find(e => e.id === id);
if (!entry) { this.notify('Entry not found', 'error'); return; }
document.getElementById('clEditId').value = entry.id;
document.getElementById('clVersion').value = entry.version || '';
document.getElementById('clDate').value = entry.date || '';
document.getElementById('clType').value = entry.type || 'update';
document.getElementById('clTitle').value = entry.title || '';
document.getElementById('clDescription').value = entry.description || '';
document.getElementById('clChanges').value = (entry.changes || []).join('\n');
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (err) {
this.notify('Failed to load entry for editing', 'error');
}
},
async deleteChangelog(id) {
if (!confirm('Delete this changelog entry?')) return;
try {
const res = await fetch(this.API + '/changelog/' + id, { method: 'DELETE', headers: this.authHeaders() });
if (!res.ok) throw new Error('Delete failed');
this.notify('Entry deleted');
this.loadChangelog();
} catch (err) {
this.notify('Failed to delete entry', 'error');
}
},
/* ─────────────────── SITREP ─────────────────── */
async loadSitrep() {
try {
// Load list
const listRes = await fetch(this.API + '/sitrep/list', { headers: this.authHeaders() });
if (listRes.ok) {
const listData = await listRes.json();
const dates = listData.dates || [];
document.getElementById('sitrepTotal').textContent = listData.total || 0;
document.getElementById('sitrepLatestDate').textContent = dates.length ? dates[0].date : 'NONE';
}
// Load today's sitrep
const todayRes = await fetch(this.API + '/sitrep', { headers: this.authHeaders() });
if (todayRes.ok) {
const sitrep = await todayRes.json();
if (sitrep && !sitrep.error) {
document.getElementById('sitrepStatus').textContent = sitrep.is_today ? 'TODAY ✓' : 'STALE';
document.getElementById('sitrepStatus').style.color = sitrep.is_today ? '#00ffc8' : '#ffcc00';
const summary = document.getElementById('sitrepTodaySummary');
if (summary) {
summary.style.display = 'block';
document.getElementById('sitrepHeadline').textContent = sitrep.headline || '';
document.getElementById('sitrepSummaryText').innerHTML = (sitrep.sections || []).map(s =>
`<div style="margin-bottom:1rem;"><strong style="color:#00ffc8;">${this.escapeHtml(s.title || '')}</strong><p style="color:#aaa;">${this.escapeHtml(s.content || '')}</p></div>`
).join('');
}
} else {
document.getElementById('sitrepStatus').textContent = 'NONE';
document.getElementById('sitrepStatus').style.color = '#ff4444';
}
} else {
document.getElementById('sitrepStatus').textContent = 'NONE';
document.getElementById('sitrepStatus').style.color = '#ff4444';
}
// Archive list
const archRes = await fetch(this.API + '/sitrep/list', { headers: this.authHeaders() });
if (archRes.ok) {
const archData = await archRes.json();
const archEl = document.getElementById('sitrepArchive');
if (archEl) {
const dates = archData.dates || [];
if (dates.length === 0) {
archEl.innerHTML = '<div style="color:#555;">No reports in archive</div>';
} else {
archEl.innerHTML = dates.map(d => `<div class="admin-card" style="margin-bottom:0.5rem;display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>${d.date}</strong>
<span style="color:#aaa;margin-left:1rem;">${this.escapeHtml(d.headline || '')}</span>
</div>
<div style="color:#555;">
${d.sources_used || 0} sources · ${d.model || 'unknown'}
</div>
</div>`).join('');
}
}
}
} catch (err) {
console.error('SITREP load error:', err);
this.notify('Failed to load SITREP data', 'error');
}
},
async generateSitrep() {
const btn = document.getElementById('sitrepGenerateBtn');
if (btn) { btn.disabled = true; btn.textContent = '⏳ GENERATING...'; }
try {
const res = await fetch(this.API + '/sitrep/generate', { method: 'POST', headers: this.authHeaders() });
const data = await res.json();
if (res.ok && data.status === 'ok') {
this.notify('SITREP generated for ' + (data.date || 'today'));
this.loadSitrep();
} else {
this.notify('Generation failed: ' + (data.message || 'Unknown error'), 'error');
}
} catch (err) {
console.error('SITREP generate error:', err);
this.notify('Failed to generate SITREP', 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = '⚡ GENERATE NOW'; }
}
},
/* ─────────────────── DATA SYNC ─────────────────── */
async loadDataSync() {
try {
const res = await fetch(this.API + '/sync/status', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load sync status');
const data = await res.json();
// Contraband stats
const cb = data.contraband || {};
document.getElementById('syncCbEntries').textContent = (cb.total_entries || 0).toLocaleString();
document.getElementById('syncCbCategories').textContent = cb.total_categories || 0;
document.getElementById('syncCbStarred').textContent = (cb.total_starred || 0).toLocaleString();
document.getElementById('syncCbLastSync').textContent = cb.last_sync ? new Date(cb.last_sync).toLocaleString('en-GB') : 'NEVER';
// Awesomelist stats
const al = data.awesomelist || {};
document.getElementById('syncAlSectors').textContent = al.total_sectors || 0;
document.getElementById('syncAlLists').textContent = al.total_lists || 0;
document.getElementById('syncAlEntries').textContent = (al.total_entries || 0).toLocaleString();
document.getElementById('syncAlLastSync').textContent = al.last_sync ? new Date(al.last_sync).toLocaleString('en-GB') : 'NEVER';
} catch (err) {
console.error('Sync status error:', err);
this.notify('Failed to load sync status', 'error');
}
},
async runSync(type) {
const btnId = type === 'contraband' ? 'syncCbBtn' : 'syncAlBtn';
const btn = document.getElementById(btnId);
if (btn) { btn.disabled = true; btn.textContent = '⏳ SYNCING...'; }
try {
const res = await fetch(this.API + '/sync/' + type, { method: 'POST', headers: this.authHeaders() });
const data = await res.json();
// Show log
const logEl = document.getElementById('syncLog');
const logContent = document.getElementById('syncLogContent');
if (logEl && logContent) {
logEl.style.display = 'block';
logContent.textContent = data.log || data.stderr || data.message || 'No output';
}
if (res.ok && data.status === 'ok') {
this.notify(type + ' sync completed');
this.loadDataSync();
} else {
this.notify('Sync failed: ' + (data.message || data.stderr || 'Unknown error'), 'error');
}
} catch (err) {
console.error('Sync error:', err);
this.notify('Sync failed: ' + err.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = '⚡ SYNC NOW'; }
}
},
/* ─────────────────── CRON JOBS ─────────────────── */
async loadCronJobs() {
try {
const res = await fetch(this.API + '/crons', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load cron jobs');
const jobs = await res.json();
const list = document.getElementById('cronJobsList');
if (!list) return;
if (!Array.isArray(jobs) || jobs.length === 0) {
list.innerHTML = '<div style="color:#555;text-align:center;padding:2rem;">No cron jobs configured</div>';
return;
}
list.innerHTML = jobs.map(j => {
const statusColor = j.enabled ? '#00ffc8' : (j.found ? '#ffcc00' : '#ff4444');
const statusText = j.enabled ? 'ACTIVE' : (j.found ? 'DISABLED' : 'NOT FOUND');
return `<div class="admin-card" style="margin-bottom:1rem;border-left:3px solid ${statusColor};display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>${this.escapeHtml(j.name)}</strong>
<span style="color:${statusColor};margin-left:1rem;">[${statusText}]</span>
<div style="color:#555;margin-top:0.3rem;font-family:'JetBrains Mono',monospace;font-size:0.85rem;">
Schedule: <code>${this.escapeHtml(j.schedule)}</code>
</div>
</div>
<div>
${j.found ? `<button class="action-btn ${j.enabled ? '' : 'accent'}" onclick="AdminApp.toggleCron('${j.id}')" style="min-width:100px;">
${j.enabled ? '⏸ DISABLE' : '▶ ENABLE'}
</button>` : '<span style="color:#ff4444;">Not in crontab</span>'}
</div>
</div>`;
}).join('');
} catch (err) {
console.error('Cron jobs load error:', err);
this.notify('Failed to load cron jobs', 'error');
}
},
async toggleCron(jobId) {
try {
const res = await fetch(this.API + '/crons/' + jobId + '/toggle', { method: 'POST', headers: this.authHeaders() });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Toggle failed');
}
this.notify('Cron job toggled');
this.loadCronJobs();
} catch (err) {
console.error('Cron toggle error:', err);
this.notify('Failed to toggle cron: ' + err.message, 'error');
}
},
/* ─────────────────── BACKUP IMPORT ─────────────────── */
async importBackup() {
const fileInput = document.getElementById('backupImportFile');
if (!fileInput || !fileInput.files.length) {
this.notify('Please select a ZIP file first', 'error');
return;
}
if (!confirm('This will overwrite existing data files. Are you sure?')) return;
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const res = await fetch(this.API + '/backups/import', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('jaeswift_token') },
body: formData
});
const data = await res.json();
if (res.ok && data.ok) {
this.notify(`Backup restored: ${data.count} files (${data.restored.join(', ')})`);
fileInput.value = '';
} else {
this.notify('Import failed: ' + (data.error || 'Unknown error'), 'error');
}
} catch (err) {
console.error('Import error:', err);
this.notify('Failed to import backup', 'error');
}
},
};
/* ─────────────────── BOOTSTRAP ─────────────────── */