jaeswift-website/js/admin.js

1575 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ═══════════════════════════════════════════════════════════════
* JAESWIFT.XYZ — Admin Panel Controller
* Complete admin application object
* ═══════════════════════════════════════════════════════════════ */
const AdminApp = {
token: localStorage.getItem('admin_token'),
API: '/api',
currentSection: 'section-dashboard',
homepageData: null,
servicesData: [],
navData: [],
linksData: [],
themeDefaults: {
accent: '#00ffc8',
bg: '#0a0e17',
text: '#e0e0e0',
scanlines: true,
particles: true,
glitch: false,
grid: true,
fontSize: 16
},
/* ───────────────────────── AUTH ───────────────────────── */
init() {
// Login form - support both <form> submit and button click
const loginForm = document.getElementById('loginForm');
if (loginForm) {
loginForm.addEventListener('submit', (e) => this.login(e));
}
const loginBtn = document.getElementById('loginBtn');
if (loginBtn && !loginForm) {
loginBtn.addEventListener('click', (e) => this.login(e));
}
// Also handle Enter key in password field
const loginPass = document.getElementById('loginPassword');
if (loginPass) {
loginPass.addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.login(e);
});
}
// Check auth state
if (this.checkAuth()) {
this.showApp();
this.loadDashboard();
} else {
this.showLogin();
}
// Mobile hamburger
const mobileBtn = document.getElementById('mobileMenuBtn');
if (mobileBtn) {
mobileBtn.addEventListener('click', () => this.toggleSidebar());
}
// Sidebar overlay close
const overlay = document.getElementById('sidebarOverlay');
if (overlay) {
overlay.addEventListener('click', () => this.toggleSidebar());
}
// Editor: auto-slug on title input
const editTitle = document.getElementById('editorTitle');
if (editTitle) {
editTitle.addEventListener('input', () => this.autoSlug());
}
// Editor: live preview on content input
const editContent = document.getElementById('editorContent');
if (editContent) {
editContent.addEventListener('input', () => this.updatePreview());
}
// Editor toolbar buttons
document.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', () => {
this.insertMarkdown(btn.getAttribute('data-action'));
});
});
// Operator HUD sliders
['hudEnergy', 'hudMotivation', 'hudFocus', 'hudDifficulty'].forEach(id => {
const slider = document.getElementById(id);
if (slider) {
const valId = 'val' + id.replace('hud', '');
const valEl = document.getElementById(valId);
if (valEl) valEl.textContent = slider.value;
slider.addEventListener('input', () => {
if (valEl) valEl.textContent = slider.value;
});
}
});
// Theme colour sync
['AccentColor', 'BgColor', 'TextColor'].forEach(name => {
const picker = document.getElementById('theme' + name);
const hex = document.getElementById('theme' + name + 'Hex');
if (picker && hex) {
picker.addEventListener('input', () => {
hex.value = picker.value;
});
hex.addEventListener('input', () => {
if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) {
picker.value = hex.value;
}
});
}
});
// Theme font size slider
const fontSlider = document.getElementById('themeFontSize');
const valFont = document.getElementById('themeFontSizeVal');
if (fontSlider && valFont) {
valFont.textContent = fontSlider.value;
fontSlider.addEventListener('input', () => {
valFont.textContent = fontSlider.value;
});
}
},
async login(e) {
if (e && e.preventDefault) e.preventDefault();
const user = document.getElementById('loginUsername').value.trim();
const pass = document.getElementById('loginPassword').value;
const errEl = document.getElementById('loginError');
if (errEl) errEl.textContent = '';
try {
const res = await fetch(this.API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass })
});
const data = await res.json();
if (res.ok && data.token) {
this.token = data.token;
localStorage.setItem('admin_token', this.token);
this.showApp();
this.loadDashboard();
} else {
errEl.textContent = data.error || 'Authentication failed';
}
} catch (err) {
errEl.textContent = 'Connection error: ' + err.message;
}
},
logout() {
this.token = null;
localStorage.removeItem('admin_token');
this.showLogin();
},
checkAuth() {
return !!this.token;
},
authHeaders() {
return {
'Authorization': 'Bearer ' + this.token,
'Content-Type': 'application/json'
};
},
showLogin() {
const login = document.getElementById('loginScreen');
const app = document.getElementById('adminApp');
if (login) login.style.display = 'flex';
if (app) app.style.display = 'none';
},
showApp() {
const login = document.getElementById('loginScreen');
const app = document.getElementById('adminApp');
if (login) login.style.display = 'none';
if (app) app.style.display = 'flex';
},
/* ─────────────────────── NAVIGATION ─────────────────────── */
showSection(name) {
// Normalize: ensure name has section- prefix
if (!name.startsWith('section-')) name = 'section-' + name;
// Hide all sections
document.querySelectorAll('[id^="section-"]').forEach(s => {
s.style.display = 'none';
});
// Show target
const target = document.getElementById(name);
if (target) target.style.display = 'block';
this.currentSection = name;
// Update sidebar active
document.querySelectorAll('.sidebar a, .sidebar .nav-link').forEach(a => {
a.classList.remove('active');
});
const shortName = name.replace('section-', '');
const activeLink = document.querySelector(`.sidebar-link[data-section="${shortName}"]`);
if (activeLink) activeLink.classList.add('active');
// Update topbar title
const titleMap = {
'section-dashboard': 'Dashboard',
'section-posts': 'Posts',
'section-editor': 'Editor',
'section-tracks': 'Tracks',
'section-settings': 'Settings',
'section-homepage': 'Homepage',
'section-services': 'Services',
'section-navigation': 'Navigation',
'section-links': 'Links',
'section-apikeys': 'API Keys',
'section-theme': 'Theme',
'section-seo': 'SEO',
'section-contact': 'Contact',
'section-backups': 'Backups'
};
const topTitle = document.getElementById('topbarTitle') || document.querySelector('.topbar-title');
if (topTitle) topTitle.textContent = titleMap[name] || name.replace('section-', '').toUpperCase();
// Load data for section
switch (name) {
case 'section-dashboard': this.loadDashboard(); break;
case 'section-posts': this.loadPosts(); break;
case 'section-tracks': this.loadTracks(); break;
case 'section-settings': this.loadSettings(); break;
case 'section-homepage': this.loadHomepage(); break;
case 'section-services': this.loadServices(); break;
case 'section-navigation': this.loadNavigation(); break;
case 'section-links': this.loadLinks(); break;
case 'section-apikeys': this.loadApiKeys(); break;
case 'section-theme': this.loadTheme(); break;
case 'section-seo': this.loadSeo(); break;
case 'section-contact': this.loadContactSettings(); break;
}
// Close mobile sidebar
const sidebar = document.querySelector('.sidebar');
if (sidebar) sidebar.classList.remove('open');
const sOverlay = document.getElementById('sidebarOverlay');
if (sOverlay) sOverlay.classList.remove('active');
},
toggleSidebar() {
const sidebar = document.querySelector('.sidebar');
const overlay = document.getElementById('sidebarOverlay');
if (sidebar) sidebar.classList.toggle('open');
if (overlay) overlay.classList.toggle('active');
},
/* ─────────────────── NOTIFICATIONS ─────────────────── */
notify(msg, type = 'success') {
const container = document.getElementById('notification') || document.body;
const div = document.createElement('div');
div.className = 'notification notification-' + type;
div.innerHTML = `<span class="notification-icon">${type === 'success' ? '✓' : type === 'error' ? '✗' : ''}</span><span>${msg}</span>`;
// Styles
Object.assign(div.style, {
position: 'fixed',
top: '20px',
right: '20px',
padding: '14px 24px',
borderRadius: '4px',
color: '#fff',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '13px',
zIndex: '10000',
display: 'flex',
alignItems: 'center',
gap: '10px',
animation: 'slideIn 0.3s ease',
border: '1px solid',
backdropFilter: 'blur(10px)',
boxShadow: '0 4px 20px rgba(0,0,0,0.5)'
});
if (type === 'success') {
div.style.background = 'rgba(0,255,200,0.15)';
div.style.borderColor = '#00ffc8';
} else if (type === 'error') {
div.style.background = 'rgba(255,50,50,0.15)';
div.style.borderColor = '#ff3232';
} else {
div.style.background = 'rgba(0,150,255,0.15)';
div.style.borderColor = '#0096ff';
}
container.appendChild(div);
setTimeout(() => {
div.style.opacity = '0';
div.style.transition = 'opacity 0.3s ease';
setTimeout(() => div.remove(), 300);
}, 3000);
},
/* ─────────────────── DASHBOARD ─────────────────── */
async loadDashboard() {
const results = await Promise.allSettled([
fetch(this.API + '/stats', { headers: this.authHeaders() }),
fetch(this.API + '/posts', { headers: this.authHeaders() }),
fetch(this.API + '/tracks', { headers: this.authHeaders() }),
fetch(this.API + '/services', { headers: this.authHeaders() }),
fetch(this.API + '/threats', { headers: this.authHeaders() })
]);
// Stats
try {
if (results[0].status === 'fulfilled' && results[0].value.ok) {
const stats = await results[0].value.json();
const cpu = document.getElementById('statCPU');
const mem = document.getElementById('statMEM');
const disk = document.getElementById('statDISK');
if (cpu) cpu.textContent = (stats.cpu_percent || stats.cpu || 0) + '%';
if (mem) mem.textContent = (stats.memory_percent || stats.memory || 0) + '%';
if (disk) disk.textContent = (stats.disk_percent || stats.disk || 0) + '%';
}
} catch (e) { console.warn('Dashboard stats error:', e); }
// Posts count + word count
try {
if (results[1].status === 'fulfilled' && results[1].value.ok) {
const posts = await results[1].value.json();
const arr = Array.isArray(posts) ? posts : (posts.posts || []);
const dashPosts = document.getElementById('statPosts');
const dashWords = document.getElementById('statWords');
if (dashPosts) dashPosts.textContent = arr.length;
if (dashWords) {
const totalWords = arr.reduce((sum, p) => sum + (p.word_count || 0), 0);
dashWords.textContent = totalWords.toLocaleString();
}
}
} catch (e) { console.warn('Dashboard posts error:', e); }
// Tracks count
try {
if (results[2].status === 'fulfilled' && results[2].value.ok) {
const tracks = await results[2].value.json();
const arr = Array.isArray(tracks) ? tracks : (tracks.tracks || []);
const dashTracks = document.getElementById('statTracks');
if (dashTracks) dashTracks.textContent = arr.length;
}
} catch (e) { console.warn('Dashboard tracks error:', e); }
// Services grid
try {
if (results[3].status === 'fulfilled' && results[3].value.ok) {
const services = await results[3].value.json();
const arr = Array.isArray(services) ? services : (services.services || []);
const grid = document.getElementById('servicesGrid');
if (grid) {
grid.innerHTML = arr.map(svc => {
const isUp = svc.status === 'up' || svc.status === 'online' || svc.status === true;
const dotColor = isUp ? '#00ffc8' : '#ff3232';
const statusText = isUp ? 'ONLINE' : 'OFFLINE';
return `<div class="service-card">
<span class="service-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${dotColor};box-shadow:0 0 8px ${dotColor};margin-right:8px;"></span>
<span class="service-name">${this.escapeHtml(svc.name || svc.service || 'Unknown')}</span>
<span class="service-status" style="color:${dotColor};margin-left:auto;font-size:11px;">${statusText}</span>
</div>`;
}).join('');
}
} else {
const grid = document.getElementById('servicesGrid');
if (grid) grid.innerHTML = '<div style="color:#ff6600;padding:12px;">Services check timed out</div>';
}
} catch (e) { console.warn('Dashboard services error:', e); }
// Threats
try {
if (results[4].status === 'fulfilled' && results[4].value.ok) {
const threats = await results[4].value.json();
const arr = Array.isArray(threats) ? threats : (threats.threats || threats.items || []);
const container = document.getElementById('dashThreats');
if (container) {
if (arr.length === 0) {
container.innerHTML = '<div class="threat-empty">No active threats detected \u2713</div>';
} else {
container.innerHTML = arr.map(t => {
const severity = (t.severity || t.level || 'low').toLowerCase();
const sevColor = severity === 'critical' ? '#ff0040' : severity === 'high' ? '#ff6600' : severity === 'medium' ? '#ffaa00' : '#00ffc8';
return `<div class="threat-item" style="border-left:3px solid ${sevColor};padding:8px 12px;margin-bottom:6px;background:rgba(255,255,255,0.03);">
<div style="display:flex;justify-content:space-between;align-items:center;">
<strong style="color:${sevColor};">${this.escapeHtml(t.cve || t.id || t.name || 'Unknown')}</strong>
<span style="color:${sevColor};font-size:11px;text-transform:uppercase;">${severity}</span>
</div>
<div style="color:#888;font-size:12px;margin-top:4px;">${this.escapeHtml(t.description || t.summary || '')}</div>
</div>`;
}).join('');
}
}
}
} catch (e) { console.warn('Dashboard threats error:', e); }
},
/* ─────────────────── POSTS ─────────────────── */
async loadPosts() {
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 || []);
const tbody = document.getElementById('postsTableBody');
if (!tbody) return;
if (posts.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#555;">No posts yet</td></tr>';
return;
}
// Sort by date descending
posts.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
tbody.innerHTML = posts.map(p => {
const date = p.date ? new Date(p.date).toLocaleDateString('en-GB') : '—';
const words = p.word_count || 0;
return `<tr>
<td>${this.escapeHtml(p.title || 'Untitled')}</td>
<td><code>${this.escapeHtml(p.slug || '')}</code></td>
<td>${date}</td>
<td>${words.toLocaleString()}</td>
<td class="post-actions">
<button class="btn-sm btn-edit" onclick="AdminApp.editPost('${this.escapeHtml(p.slug)}')" title="Edit">✎</button>
<button class="btn-sm btn-delete" onclick="AdminApp.deletePost('${this.escapeHtml(p.slug)}')" title="Delete">✗</button>
</td>
</tr>`;
}).join('');
} catch (err) {
console.error('Posts load error:', err);
this.notify('Failed to load posts', 'error');
}
},
newPost() {
this.clearEditor();
const heading = document.getElementById('editorTitle');
if (heading) heading.textContent = 'NEW POST';
this.showSection('section-editor');
},
async editPost(slug) {
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();
const heading = document.getElementById('editorTitle');
if (heading) heading.textContent = 'EDIT POST';
document.getElementById('editorPostId').value = post.slug || slug;
document.getElementById('editorTitle').value = post.title || '';
document.getElementById('editorSlug').value = post.slug || '';
// Parse date and time
if (post.date) {
const dt = new Date(post.date);
const dateStr = dt.toISOString().split('T')[0];
const timeStr = dt.toTimeString().slice(0, 5);
document.getElementById('editorDate').value = dateStr;
document.getElementById('editorTime').value = timeStr;
} else {
document.getElementById('editorDate').value = '';
document.getElementById('editorTime').value = '';
}
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 fields
const moodEl = document.getElementById('hudMood');
if (moodEl) moodEl.value = post.mood || 'focused';
const hudFields = [
{ id: 'hudEnergy', key: 'energy', valId: 'valEnergy' },
{ id: 'hudMotivation', key: 'motivation', valId: 'valMotivation' },
{ id: 'hudFocus', key: 'focus', valId: 'valFocus' },
{ id: 'hudDifficulty', key: 'difficulty', valId: 'valDifficulty' }
];
hudFields.forEach(f => {
const el = document.getElementById(f.id);
const valEl = document.getElementById(f.valId);
if (el) {
el.value = post[f.key] || 3;
if (valEl) valEl.textContent = el.value;
}
});
const coffeeEl = document.getElementById('hudCoffee');
if (coffeeEl) coffeeEl.value = post.coffee || 0;
const bpmEl = document.getElementById('hudBPM');
if (bpmEl) bpmEl.value = post.bpm || 0;
const threatEl = document.getElementById('hudThreat');
if (threatEl) threatEl.value = post.threat_level || 'low';
this.updatePreview();
this.showSection('section-editor');
} catch (err) {
console.error('Edit post error:', err);
this.notify('Failed to load post for editing', 'error');
}
},
async deletePost(slug) {
if (!confirm('Delete post "' + slug + '"? This cannot be undone.')) return;
try {
const res = await fetch(this.API + '/posts/' + encodeURIComponent(slug), {
method: 'DELETE',
headers: this.authHeaders()
});
if (!res.ok) throw new Error('Delete failed');
this.notify('Post deleted');
this.loadPosts();
} catch (err) {
console.error('Delete post error:', err);
this.notify('Failed to delete post', 'error');
}
},
async savePost() {
const postId = document.getElementById('editorPostId').value;
const title = document.getElementById('editorTitle').value.trim();
const slug = document.getElementById('editorSlug').value.trim();
const date = document.getElementById('editorDate').value;
const time = document.getElementById('editorTime').value || '00:00';
const tags = document.getElementById('editorTags').value;
const excerpt = document.getElementById('editorExcerpt').value.trim();
const content = document.getElementById('editorContent').value;
if (!title) { this.notify('Title is required', 'error'); return; }
if (!slug) { this.notify('Slug is required', 'error'); return; }
if (!content) { this.notify('Content is required', 'error'); return; }
// Build datetime
const datetime = date ? date + 'T' + time + ':00' : new Date().toISOString();
// Word count
const wordCount = content.trim().split(/\s+/).filter(w => w.length > 0).length;
// Parse tags
const tagList = tags.split(',').map(t => t.trim()).filter(t => t.length > 0);
// Operator HUD
const mood = document.getElementById('hudMood')?.value || 'focused';
const energy = parseInt(document.getElementById('hudEnergy')?.value || 3);
const motivation = parseInt(document.getElementById('hudMotivation')?.value || 3);
const focus = parseInt(document.getElementById('hudFocus')?.value || 3);
const difficulty = parseInt(document.getElementById('hudDifficulty')?.value || 3);
const coffee = parseInt(document.getElementById('hudCoffee')?.value || 0);
const bpm = parseInt(document.getElementById('hudBPM')?.value || 0);
const threatLevel = document.getElementById('hudThreat')?.value || 'low';
const payload = {
title, slug, date: datetime, tags: tagList, excerpt, content,
word_count: wordCount,
mood, energy, motivation, focus, difficulty, coffee, bpm, threat_level: threatLevel
};
const isEdit = !!postId;
const url = isEdit ? this.API + '/posts/' + encodeURIComponent(postId) : this.API + '/posts';
const method = isEdit ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method,
headers: this.authHeaders(),
body: JSON.stringify(payload)
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Save failed');
}
this.notify(isEdit ? 'Post updated' : 'Post created');
this.showSection('section-posts');
} catch (err) {
console.error('Save post error:', err);
this.notify('Failed to save: ' + err.message, 'error');
}
},
cancelEdit() {
this.clearEditor();
this.showSection('section-posts');
},
clearEditor() {
document.getElementById('editorPostId').value = '';
document.getElementById('editorTitle').value = '';
document.getElementById('editorSlug').value = '';
document.getElementById('editorDate').value = new Date().toISOString().split('T')[0];
document.getElementById('editorTime').value = new Date().toTimeString().slice(0, 5);
document.getElementById('editorTags').value = '';
document.getElementById('editorExcerpt').value = '';
document.getElementById('editorContent').value = '';
const preview = document.getElementById('editorPreview');
if (preview) preview.innerHTML = '';
// Reset HUD
const moodEl = document.getElementById('hudMood');
if (moodEl) moodEl.value = 'focused';
['hudEnergy', 'hudMotivation', 'hudFocus', 'hudDifficulty'].forEach(id => {
const el = document.getElementById(id);
if (el) {
el.value = 3;
const valId = 'val' + id.replace('hud', '');
const valEl = document.getElementById(valId);
if (valEl) valEl.textContent = '3';
}
});
const coffeeEl = document.getElementById('hudCoffee');
if (coffeeEl) coffeeEl.value = 0;
const bpmEl = document.getElementById('hudBPM');
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 ─────────────────── */
insertMarkdown(action) {
const textarea = document.getElementById('editorContent');
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = textarea.value.substring(start, end);
const before = textarea.value.substring(0, start);
const after = textarea.value.substring(end);
let insert = '';
switch (action) {
case 'bold':
insert = `**${selected || 'bold text'}**`;
break;
case 'italic':
insert = `*${selected || 'italic text'}*`;
break;
case 'strikethrough':
insert = `~~${selected || 'strikethrough'}~~`;
break;
case 'code':
insert = '`' + (selected || 'code') + '`';
break;
case 'code-block':
insert = '\n```\n' + (selected || 'code block') + '\n```\n';
break;
case 'h1':
insert = '\n# ' + (selected || 'Heading 1') + '\n';
break;
case 'h2':
insert = '\n## ' + (selected || 'Heading 2') + '\n';
break;
case 'h3':
insert = '\n### ' + (selected || 'Heading 3') + '\n';
break;
case 'link':
insert = selected ? `[${selected}](url)` : '[link text](url)';
break;
case 'image':
insert = selected ? `![${selected}](url)` : '![alt text](image-url)';
break;
case 'quote':
insert = '\n> ' + (selected || 'blockquote') + '\n';
break;
case 'list-ul':
if (selected) {
insert = '\n' + selected.split('\n').map(l => '- ' + l).join('\n') + '\n';
} else {
insert = '\n- Item 1\n- Item 2\n- Item 3\n';
}
break;
case 'list-ol':
if (selected) {
insert = '\n' + selected.split('\n').map((l, i) => (i + 1) + '. ' + l).join('\n') + '\n';
} else {
insert = '\n1. Item 1\n2. Item 2\n3. Item 3\n';
}
break;
case 'hr':
insert = '\n---\n';
break;
default:
insert = selected;
}
textarea.value = before + insert + after;
textarea.focus();
const newCursor = start + insert.length;
textarea.setSelectionRange(newCursor, newCursor);
this.updatePreview();
},
updatePreview() {
const content = document.getElementById('editorContent')?.value || '';
const preview = document.getElementById('editorPreview');
if (!preview) return;
let html = this.escapeHtml(content);
// Code blocks (must process before inline code)
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Headers
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Bold and italic
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Strikethrough
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Blockquotes
html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
// Horizontal rule
html = html.replace(/^---$/gm, '<hr>');
// Images
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;">');
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:#00ffc8;">$1</a>');
// Unordered lists
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
// Ordered lists
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
// Paragraphs: double newline
html = html.replace(/\n\n/g, '</p><p>');
html = '<p>' + html + '</p>';
// Single newlines to br (within paragraphs)
html = html.replace(/\n/g, '<br>');
// Clean empty paragraphs
html = html.replace(/<p><\/p>/g, '');
preview.innerHTML = html;
},
autoSlug() {
const title = document.getElementById('editorTitle')?.value || '';
const slug = title
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const slugEl = document.getElementById('editorSlug');
if (slugEl) slugEl.value = slug;
},
/* ─────────────────── TRACKS ─────────────────── */
async loadTracks() {
try {
const res = await fetch(this.API + '/tracks', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load tracks');
const data = await res.json();
const tracks = Array.isArray(data) ? data : (data.tracks || []);
const container = document.getElementById('tracksList');
if (!container) return;
if (tracks.length === 0) {
container.innerHTML = '<div class="empty-state">No tracks in library</div>';
return;
}
container.innerHTML = tracks.map((t, i) => {
const coverHtml = t.cover
? `<img src="${this.escapeHtml(t.cover)}" alt="cover" style="width:40px;height:40px;border-radius:4px;object-fit:cover;margin-right:12px;">`
: '<div style="width:40px;height:40px;border-radius:4px;background:#1a1f2e;margin-right:12px;display:flex;align-items:center;justify-content:center;color:#555;">♪</div>';
return `<div class="track-item" style="display:flex;align-items:center;padding:10px;border-bottom:1px solid rgba(255,255,255,0.05);">
${coverHtml}
<div style="flex:1;">
<div style="font-weight:600;color:#e0e0e0;">${this.escapeHtml(t.artist || 'Unknown')}${this.escapeHtml(t.title || 'Untitled')}</div>
<div style="font-size:12px;color:#555;">${this.escapeHtml(t.album || '')} ${t.genre ? '· ' + this.escapeHtml(t.genre) : ''}</div>
</div>
${t.url ? `<a href="${this.escapeHtml(t.url)}" target="_blank" style="color:#00ffc8;margin-right:12px;font-size:12px;">▶</a>` : ''}
<button class="btn-sm btn-delete" onclick="AdminApp.deleteTrack(${i})" title="Delete">✗</button>
</div>`;
}).join('');
} catch (err) {
console.error('Tracks load error:', err);
this.notify('Failed to load tracks', 'error');
}
},
async addTrack() {
const title = document.getElementById('trackTitle')?.value.trim();
const artist = document.getElementById('trackArtist')?.value.trim();
const album = document.getElementById('trackAlbum')?.value.trim();
const genre = document.getElementById('trackGenre')?.value.trim();
const url = document.getElementById('trackURL')?.value.trim();
const cover = document.getElementById('trackCover')?.value.trim();
if (!title || !artist) {
this.notify('Title and artist are required', 'error');
return;
}
const payload = { title, artist, album, genre, url, cover };
try {
const res = await fetch(this.API + '/tracks', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Failed to add track');
this.notify('Track added');
// Clear inputs
['trackTitle', 'trackArtist', 'trackAlbum', 'trackGenre', 'trackURL', 'trackCover'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
this.loadTracks();
} catch (err) {
console.error('Add track error:', err);
this.notify('Failed to add track', 'error');
}
},
async deleteTrack(index) {
if (!confirm('Delete this track?')) return;
try {
const res = await fetch(this.API + '/tracks/' + index, {
method: 'DELETE',
headers: this.authHeaders()
});
if (!res.ok) throw new Error('Delete failed');
this.notify('Track deleted');
this.loadTracks();
} catch (err) {
console.error('Delete track error:', err);
this.notify('Failed to delete track', 'error');
}
},
/* ─────────────────── SETTINGS (WIDGET TOGGLES) ─────────────────── */
async loadSettings() {
try {
const res = await fetch(this.API + '/settings', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load settings');
const settings = await res.json();
// Set toggle states
document.querySelectorAll('[data-key]').forEach(toggle => {
const key = toggle.getAttribute('data-key');
if (settings.hasOwnProperty(key)) {
toggle.checked = !!settings[key];
}
});
} catch (err) {
console.error('Settings load error:', err);
this.notify('Failed to load settings', 'error');
}
},
async saveSettings() {
const settings = {};
document.querySelectorAll('[data-key]').forEach(toggle => {
settings[toggle.getAttribute('data-key')] = toggle.checked;
});
try {
const res = await fetch(this.API + '/settings', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(settings)
});
if (!res.ok) throw new Error('Save failed');
this.notify('Settings saved');
} catch (err) {
console.error('Save settings error:', err);
this.notify('Failed to save settings', 'error');
}
},
/* ─────────────────── HOMEPAGE ─────────────────── */
async loadHomepage() {
try {
const res = await fetch(this.API + '/homepage', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load homepage config');
const data = await res.json();
this.homepageData = data;
document.getElementById('heroTitle').value = data.hero_title || '';
document.getElementById('heroSubtitle').value = data.hero_subtitle || '';
document.getElementById('heroTagline').value = data.hero_tagline || '';
document.getElementById('aboutText').value = data.about_text || '';
this.renderHomepageSections(data.sections || []);
} catch (err) {
console.error('Homepage load error:', err);
this.notify('Failed to load homepage config', 'error');
}
},
renderHomepageSections(sections) {
const container = document.getElementById('homepageSections');
if (!container) return;
const defaultSections = [
{ key: 'about', label: 'About', visible: true },
{ key: 'blog', label: 'Blog', visible: true },
{ key: 'dev-cards', label: 'Dev Cards', visible: true },
{ key: 'links', label: 'Links', visible: true },
{ key: 'contact', label: 'Contact', visible: true },
{ key: 'terminal', label: 'Terminal', visible: true }
];
const sectionList = sections.length > 0 ? sections : defaultSections;
if (this.homepageData) this.homepageData.sections = sectionList;
container.innerHTML = sectionList.map((s, i) => {
const key = s.key || s.name || s;
const label = s.label || s.name || key;
const visible = s.visible !== false;
return `<div class="section-item" data-section="${this.escapeHtml(key)}" style="display:flex;align-items:center;padding:10px;border:1px solid rgba(255,255,255,0.08);margin-bottom:6px;border-radius:4px;background:rgba(255,255,255,0.02);">
<span style="flex:1;color:#e0e0e0;font-weight:500;">${this.escapeHtml(label)}</span>
<label class="toggle-switch" style="margin-right:12px;">
<input type="checkbox" ${visible ? 'checked' : ''} onchange="AdminApp.toggleHomepageSection('${this.escapeHtml(key)}', this.checked)">
<span class="toggle-slider"></span>
</label>
<button class="btn-sm" onclick="AdminApp.moveSection('${this.escapeHtml(key)}', 'up')" ${i === 0 ? 'disabled' : ''} title="Move up" style="margin-right:4px;">▲</button>
<button class="btn-sm" onclick="AdminApp.moveSection('${this.escapeHtml(key)}', 'down')" ${i === sectionList.length - 1 ? 'disabled' : ''} title="Move down">▼</button>
</div>`;
}).join('');
},
toggleHomepageSection(key, visible) {
if (!this.homepageData || !this.homepageData.sections) return;
const section = this.homepageData.sections.find(s => (s.key || s.name || s) === key);
if (section) section.visible = visible;
},
moveSection(sectionKey, direction) {
if (!this.homepageData || !this.homepageData.sections) return;
const sections = this.homepageData.sections;
const idx = sections.findIndex(s => (s.key || s.name || s) === sectionKey);
if (idx === -1) return;
const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= sections.length) return;
// Swap
[sections[idx], sections[targetIdx]] = [sections[targetIdx], sections[idx]];
this.renderHomepageSections(sections);
},
async saveHomepage() {
const payload = {
hero_title: document.getElementById('heroTitle')?.value.trim() || '',
hero_subtitle: document.getElementById('heroSubtitle')?.value.trim() || '',
hero_tagline: document.getElementById('heroTagline')?.value.trim() || '',
about_text: document.getElementById('aboutText')?.value.trim() || '',
sections: this.homepageData?.sections || []
};
try {
const res = await fetch(this.API + '/homepage', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Save failed');
this.notify('Homepage config saved');
} catch (err) {
console.error('Save homepage error:', err);
this.notify('Failed to save homepage config', 'error');
}
},
/* ─────────────────── SERVICES ─────────────────── */
async loadServices() {
try {
const res = await fetch(this.API + '/services/managed', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load services');
const data = await res.json();
this.servicesData = Array.isArray(data) ? data : (data.services || []);
const container = document.getElementById('servicesList');
if (!container) return;
if (this.servicesData.length === 0) {
container.innerHTML = '<div class="empty-state">No managed services</div>';
return;
}
container.innerHTML = this.servicesData.map((s, i) => {
return `<div class="service-list-item" style="display:flex;align-items:center;padding:10px;border-bottom:1px solid rgba(255,255,255,0.05);">
<div style="flex:1;">
<div style="font-weight:600;color:#e0e0e0;">${this.escapeHtml(s.name || 'Unnamed')}</div>
<div style="font-size:12px;color:#555;">${this.escapeHtml(s.url || '')}</div>
</div>
<button class="btn-sm btn-delete" onclick="AdminApp.deleteService(${i})" title="Delete">✗</button>
</div>`;
}).join('');
} catch (err) {
console.error('Services load error:', err);
this.notify('Failed to load services', 'error');
}
},
async addService() {
const name = document.getElementById('serviceName')?.value.trim();
const url = document.getElementById('serviceURL')?.value.trim();
if (!name) {
this.notify('Service name is required', 'error');
return;
}
this.servicesData.push({ name, url });
try {
const res = await fetch(this.API + '/services/managed', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify({ name, url })
});
if (!res.ok) throw new Error('Save failed');
this.notify('Service added');
document.getElementById('serviceName').value = '';
document.getElementById('serviceURL').value = '';
this.loadServices();
} catch (err) {
console.error('Add service error:', err);
this.servicesData.pop();
this.notify('Failed to add service', 'error');
}
},
async deleteService(index) {
if (!confirm('Delete this service?')) return;
this.servicesData.splice(index, 1);
try {
const res = await fetch(this.API + '/services/managed', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify({ name, url })
});
if (!res.ok) throw new Error('Delete failed');
this.notify('Service deleted');
this.loadServices();
} catch (err) {
console.error('Delete service error:', err);
this.notify('Failed to delete service', 'error');
}
},
/* ─────────────────── NAVIGATION EDITOR ─────────────────── */
async loadNavigation() {
try {
const res = await fetch(this.API + '/navigation', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load navigation');
const data = await res.json();
this.navData = Array.isArray(data) ? data : (data.items || []);
const container = document.getElementById('navList');
if (!container) return;
if (this.navData.length === 0) {
container.innerHTML = '<div class="empty-state">No navigation items</div>';
return;
}
// Sort by order
this.navData.sort((a, b) => (a.order || 0) - (b.order || 0));
container.innerHTML = this.navData.map((item, i) => {
return `<div class="nav-list-item" style="display:flex;align-items:center;padding:10px;border-bottom:1px solid rgba(255,255,255,0.05);">
<span style="color:#00ffc8;font-size:12px;margin-right:12px;min-width:30px;">#${item.order || i}</span>
<div style="flex:1;">
<div style="font-weight:600;color:#e0e0e0;">${this.escapeHtml(item.label || 'Unnamed')}</div>
<div style="font-size:12px;color:#555;">${this.escapeHtml(item.url || '')}</div>
</div>
<button class="btn-sm btn-delete" onclick="AdminApp.deleteNavItem(${i})" title="Delete">✗</button>
</div>`;
}).join('');
} catch (err) {
console.error('Navigation load error:', err);
this.notify('Failed to load navigation', 'error');
}
},
async addNavItem() {
const label = document.getElementById('navLabel')?.value.trim();
const url = document.getElementById('navURL')?.value.trim();
const order = parseInt(document.getElementById('navOrder')?.value || 0);
if (!label || !url) {
this.notify('Label and URL are required', 'error');
return;
}
this.navData.push({ label, url, order });
try {
const res = await fetch(this.API + '/navigation', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify({ label, url, order })
});
if (!res.ok) throw new Error('Save failed');
this.notify('Nav item added');
document.getElementById('navLabel').value = '';
document.getElementById('navURL').value = '';
document.getElementById('navOrder').value = '';
this.loadNavigation();
} catch (err) {
console.error('Add nav item error:', err);
this.navData.pop();
this.notify('Failed to add nav item', 'error');
}
},
async deleteNavItem(index) {
if (!confirm('Delete this navigation item?')) return;
try {
const res = await fetch(this.API + '/navigation/' + index, {
method: 'DELETE',
headers: this.authHeaders()
});
if (!res.ok) throw new Error('Delete failed');
this.notify('Nav item deleted');
this.loadNavigation();
} catch (err) {
console.error('Delete nav item error:', err);
this.notify('Failed to delete nav item', 'error');
}
},
/* ─────────────────── LINKS ─────────────────── */
async loadLinks() {
try {
const res = await fetch(this.API + '/links', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load links');
const data = await res.json();
this.linksData = Array.isArray(data) ? data : (data.links || []);
const container = document.getElementById('linksList');
if (!container) return;
if (this.linksData.length === 0) {
container.innerHTML = '<div class="empty-state">No links configured</div>';
return;
}
container.innerHTML = this.linksData.map((link, i) => {
return `<div class="link-list-item" style="display:flex;align-items:center;padding:10px;border-bottom:1px solid rgba(255,255,255,0.05);">
<span style="font-size:18px;margin-right:12px;">${this.escapeHtml(link.icon || '🔗')}</span>
<div style="flex:1;">
<div style="font-weight:600;color:#e0e0e0;">${this.escapeHtml(link.name || 'Unnamed')}</div>
<div style="font-size:12px;color:#555;">${this.escapeHtml(link.url || '')} ${link.category ? '<span style="color:#00ffc8;">· ' + this.escapeHtml(link.category) + '</span>' : ''}</div>
</div>
<button class="btn-sm btn-delete" onclick="AdminApp.deleteLink(${i})" title="Delete">✗</button>
</div>`;
}).join('');
} catch (err) {
console.error('Links load error:', err);
this.notify('Failed to load links', 'error');
}
},
async addLink() {
const name = document.getElementById('linkName')?.value.trim();
const url = document.getElementById('linkURL')?.value.trim();
const icon = document.getElementById('linkIcon')?.value.trim();
const category = document.getElementById('linkCategory')?.value.trim();
if (!name || !url) {
this.notify('Name and URL are required', 'error');
return;
}
this.linksData.push({ name, url, icon: icon || '🔗', category: category || 'general' });
try {
const res = await fetch(this.API + '/links', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify({ name, url, icon: icon || '🔗', category: category || 'general' })
});
if (!res.ok) throw new Error('Save failed');
this.notify('Link added');
['linkName', 'linkURL', 'linkIcon', 'linkCategory'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
this.loadLinks();
} catch (err) {
console.error('Add link error:', err);
this.linksData.pop();
this.notify('Failed to add link', 'error');
}
},
async deleteLink(index) {
if (!confirm('Delete this link?')) return;
this.linksData.splice(index, 1);
try {
const res = await fetch(this.API + '/links', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify({ name, url, icon: icon || '🔗', category: category || 'general' })
});
if (!res.ok) throw new Error('Delete failed');
this.notify('Link deleted');
this.loadLinks();
} catch (err) {
console.error('Delete link error:', err);
this.notify('Failed to delete link', 'error');
}
},
/* ─────────────────── API KEYS ─────────────────── */
async loadApiKeys() {
try {
const res = await fetch(this.API + '/apikeys', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load API keys');
const data = await res.json();
// Weather
this.setVal('apiWeatherKey', data.weather_api_key);
// Spotify
this.setVal('apiSpotifyClientId', data.spotify_client_id);
this.setVal('apiSpotifyClientSecret', data.spotify_client_secret);
this.setVal('apiSpotifyRefreshToken', data.spotify_refresh_token);
// SMTP
this.setVal('apiSmtpHost', data.smtp_host);
this.setVal('apiSmtpPort', data.smtp_port);
this.setVal('apiSmtpUser', data.smtp_user);
this.setVal('apiSmtpPass', data.smtp_pass);
// Discord / GitHub
this.setVal('apiDiscordWebhook', data.discord_webhook);
this.setVal('apiGithubToken', data.github_token);
// Custom APIs
for (let i = 1; i <= 3; i++) {
this.setVal(`apiCustom${i}Name`, data[`custom_api_${i}_name`]);
this.setVal(`apiCustom${i}Key`, data[`custom_api_${i}_key`]);
this.setVal(`apiCustom${i}URL`, data[`custom_api_${i}_url`]);
}
} catch (err) {
console.error('API keys load error:', err);
this.notify('Failed to load API keys', 'error');
}
},
async saveApiKey(group) {
const data = {};
switch (group) {
case 'weather':
data.api_key = this.getVal('apiWeatherKey');
break;
case 'spotify':
data.client_id = this.getVal('apiSpotifyClientId');
data.client_secret = this.getVal('apiSpotifyClientSecret');
data.refresh_token = this.getVal('apiSpotifyRefreshToken');
break;
case 'smtp':
data.host = this.getVal('apiSmtpHost');
data.port = this.getVal('apiSmtpPort');
data.user = this.getVal('apiSmtpUser');
data.pass = this.getVal('apiSmtpPass');
break;
case 'discord':
data.webhook = this.getVal('apiDiscordWebhook');
break;
case 'github':
data.token = this.getVal('apiGithubToken');
break;
case 'custom1':
data.name = this.getVal('apiCustom1Name');
data.key = this.getVal('apiCustom1Key');
data.url = this.getVal('apiCustom1URL');
break;
case 'custom2':
data.name = this.getVal('apiCustom2Name');
data.key = this.getVal('apiCustom2Key');
data.url = this.getVal('apiCustom2URL');
break;
case 'custom3':
data.name = this.getVal('apiCustom3Name');
data.key = this.getVal('apiCustom3Key');
data.url = this.getVal('apiCustom3URL');
break;
default:
this.notify('Unknown API key group: ' + group, 'error');
return;
}
try {
const res = await fetch(this.API + '/apikeys', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify({ group, data })
});
if (!res.ok) throw new Error('Save failed');
this.notify('API keys saved (' + group + ')');
} catch (err) {
console.error('Save API key error:', err);
this.notify('Failed to save API keys', 'error');
}
},
/* ─────────────────── THEME ─────────────────── */
async loadTheme() {
try {
const res = await fetch(this.API + '/theme', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load theme');
const data = await res.json();
// Colours
this.setVal('themeAccentColor', data.accent || this.themeDefaults.accent);
this.setVal('themeAccentColorHex', data.accent || this.themeDefaults.accent);
this.setVal('themeBgColor', data.bg || this.themeDefaults.bg);
this.setVal('themeBgColorHex', data.bg || this.themeDefaults.bg);
this.setVal('themeTextColor', data.text || this.themeDefaults.text);
this.setVal('themeTextColorHex', data.text || this.themeDefaults.text);
// Effect toggles
this.setChecked('themeScanlines', data.scanlines !== undefined ? data.scanlines : this.themeDefaults.scanlines);
this.setChecked('themeParticles', data.particles !== undefined ? data.particles : this.themeDefaults.particles);
this.setChecked('themeGlitch', data.glitch !== undefined ? data.glitch : this.themeDefaults.glitch);
this.setChecked('themeGridBg', data.grid !== undefined ? data.grid : this.themeDefaults.grid);
// Font size
const fontSize = data.font_size || this.themeDefaults.fontSize;
this.setVal('themeFontSize', fontSize);
const valFont = document.getElementById('themeFontSizeVal');
if (valFont) valFont.textContent = fontSize;
} catch (err) {
console.error('Theme load error:', err);
this.notify('Failed to load theme', 'error');
}
},
async saveTheme() {
const payload = {
accent: this.getVal('themeAccentColorHex') || this.getVal('themeAccentColor'),
bg: this.getVal('themeBgColorHex') || this.getVal('themeBgColor'),
text: this.getVal('themeTextColorHex') || this.getVal('themeTextColor'),
scanlines: this.getChecked('themeScanlines'),
particles: this.getChecked('themeParticles'),
glitch: this.getChecked('themeGlitch'),
grid: this.getChecked('themeGridBg'),
font_size: parseInt(this.getVal('themeFontSize') || 16)
};
try {
const res = await fetch(this.API + '/theme', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Save failed');
this.notify('Theme saved');
} catch (err) {
console.error('Save theme error:', err);
this.notify('Failed to save theme', 'error');
}
},
resetTheme() {
if (!confirm('Reset theme to defaults?')) return;
this.setVal('themeAccentColor', this.themeDefaults.accent);
this.setVal('themeAccentColorHex', this.themeDefaults.accent);
this.setVal('themeBgColor', this.themeDefaults.bg);
this.setVal('themeBgColorHex', this.themeDefaults.bg);
this.setVal('themeTextColor', this.themeDefaults.text);
this.setVal('themeTextColorHex', this.themeDefaults.text);
this.setChecked('themeScanlines', this.themeDefaults.scanlines);
this.setChecked('themeParticles', this.themeDefaults.particles);
this.setChecked('themeGlitch', this.themeDefaults.glitch);
this.setChecked('themeGridBg', this.themeDefaults.grid);
this.setVal('themeFontSize', this.themeDefaults.fontSize);
const valFont = document.getElementById('themeFontSizeVal');
if (valFont) valFont.textContent = this.themeDefaults.fontSize;
this.notify('Theme reset to defaults — save to apply', 'info');
},
syncColorInput(pickerId, hexId) {
const picker = document.getElementById(pickerId);
const hex = document.getElementById(hexId);
if (picker && hex) hex.value = picker.value;
},
/* ─────────────────── SEO ─────────────────── */
async loadSeo() {
try {
const res = await fetch(this.API + '/seo', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load SEO settings');
const data = await res.json();
this.setVal('seoTitle', data.title);
this.setVal('seoDescription', data.description);
this.setVal('seoKeywords', data.keywords);
this.setVal('seoOgImage', data.og_image);
this.setVal('seoFavicon', data.favicon);
} catch (err) {
console.error('SEO load error:', err);
this.notify('Failed to load SEO settings', 'error');
}
},
async saveSeo() {
const payload = {
title: this.getVal('seoTitle'),
description: this.getVal('seoDescription'),
keywords: this.getVal('seoKeywords'),
og_image: this.getVal('seoOgImage'),
favicon: this.getVal('seoFavicon')
};
try {
const res = await fetch(this.API + '/seo', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Save failed');
this.notify('SEO settings saved');
} catch (err) {
console.error('Save SEO error:', err);
this.notify('Failed to save SEO settings', 'error');
}
},
/* ─────────────────── CONTACT ─────────────────── */
async loadContactSettings() {
try {
const res = await fetch(this.API + '/contact-settings', { headers: this.authHeaders() });
if (!res.ok) throw new Error('Failed to load contact settings');
const data = await res.json();
this.setVal('contactEmail', data.email);
this.setVal('contactAutoReply', data.auto_reply);
this.setChecked('contactFormEnabled', data.form_enabled !== false);
} catch (err) {
console.error('Contact settings load error:', err);
this.notify('Failed to load contact settings', 'error');
}
},
async saveContactSettings() {
const payload = {
email: this.getVal('contactEmail'),
auto_reply: this.getVal('contactAutoReply'),
form_enabled: this.getChecked('contactFormEnabled')
};
try {
const res = await fetch(this.API + '/contact-settings', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Save failed');
this.notify('Contact settings saved');
} catch (err) {
console.error('Save contact settings error:', err);
this.notify('Failed to save contact settings', 'error');
}
},
/* ─────────────────── BACKUPS ─────────────────── */
async downloadBackup(type) {
try {
const url = this.API + '/backups/' + type;
const res = await fetch(url, { headers: this.authHeaders() });
if (!res.ok) throw new Error('Download failed');
const blob = await res.blob();
const filename = type === 'all' ? 'jaeswift-backup-all.zip' : type + '.json';
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
this.notify('Backup downloaded: ' + filename);
} catch (err) {
console.error('Backup download error:', err);
this.notify('Failed to download backup', 'error');
}
},
/* ─────────────────── UTILITIES ─────────────────── */
escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
},
setVal(id, value) {
const el = document.getElementById(id);
if (el) el.value = value || '';
},
getVal(id) {
const el = document.getElementById(id);
return el ? el.value.trim() : '';
},
setChecked(id, checked) {
const el = document.getElementById(id);
if (el) el.checked = !!checked;
},
getChecked(id) {
const el = document.getElementById(id);
return el ? el.checked : false;
}
};
/* ─────────────────── BOOTSTRAP ─────────────────── */
document.addEventListener('DOMContentLoaded', () => AdminApp.init());