- Navigation: 6 top-level items (BASE, TRANSMISSIONS, ARMOURY, INTEL, SAFEHOUSE, COMMS) with dropdown children - nav.js: renders nested dropdown submenus, mobile tap-to-toggle support - Theme: tactical green (#00ff41) accent, deep black (#0a0a0a) bg, amber (#c9a227) secondary - 176 colour replacements across 4 CSS + 3 JS files - Mobile: responsive dropdowns with slide animation - Updated navigation.json with full nested structure
1575 lines
66 KiB
JavaScript
1575 lines
66 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════════
|
||
* 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: '#00ff41',
|
||
bg: '#111111',
|
||
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 = '#00ff41';
|
||
} 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 ? '#00ff41' : '#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' ? '#c9a227' : '#00ff41';
|
||
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 ? `` : '';
|
||
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(/^> (.+)$/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:#00ff41;">$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:#00ff41;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:#00ff41;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:#00ff41;">· ' + 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());
|