fix: resolve all critical audit issues

- Fix 60 DOM ID mismatches in admin.js (editor, dashboard, API keys, theme, services, navigation, links)
- Add 51 missing CSS classes to admin.css (sidebar, topbar, login, editor, tables, settings, backups, etc)
- Fix 5 undefined onclick methods in admin.html (saveContact, saveSEO, remove unused save buttons)
- Fix API payload mismatches: services/nav/links send single object, apikeys nested {group, data} format
- Replace Promise.all with Promise.allSettled in loadDashboard for resilient loading
- Fix /api/services timeout: ThreadPoolExecutor parallel checks + timeout=2s
- Add /etc/hosts entries on VPS for subdomain resolution from localhost
- Add JSON error handlers (400, 401, 404, 500) to Flask API
- Suppress InsecureRequestWarning in Flask
- Fix dashThreats container ID mismatch in admin.html
- Delete stale root-level JS files from VPS
This commit is contained in:
jae 2026-04-01 00:54:20 +00:00
parent cf737b3804
commit ccbd59fcd4
5 changed files with 1371 additions and 178 deletions

658
AUDIT_REPORT.md Normal file
View file

@ -0,0 +1,658 @@
# JAESWIFT.XYZ — FULL SITE AUDIT REPORT
**Date:** 2026-04-01
**Auditor:** Agent Zero Master Developer
**Scope:** All HTML, CSS, JS, Flask API, nginx config, live endpoint testing
---
## EXECUTIVE SUMMARY
The site's core infrastructure works: nginx serves pages (200 OK), Flask API runs on port 5000, all 9 blog post slugs resolve correctly, and static assets load. However, the **admin panel is critically broken** due to a systematic naming mismatch between `admin.js` and `admin.html` — 60 DOM element IDs referenced in JavaScript don't exist in the HTML. Additionally, 5 onclick handlers call undefined methods, 51 CSS classes are used in HTML but never defined, and several API payload shapes don't match between frontend and backend.
### Issue Count by Severity
| Severity | Count |
|----------|-------|
| CRITICAL | 8 |
| HIGH | 12 |
| MEDIUM | 15 |
| LOW | 7 |
---
## 1. CRITICAL ISSUES
### 1.1 ❌ CRITICAL: 60 DOM ID Mismatches — admin.js ↔ admin.html
**Impact:** The entire admin panel is non-functional. Every section that reads/writes form fields silently fails because `getElementById()` returns `null`.
#### 1.1.1 Editor Section (16 mismatches)
| admin.js uses | admin.html has | File:Line (JS) |
|---|---|---|
| `postId` | `editorPostId` | admin.js:451,524 |
| `editTitle` | `editorTitle` | admin.js:66,452,525 |
| `editSlug` | `editorSlug` | admin.js:453,526 |
| `editDate` | `editorDate` | admin.js:460,463,527 |
| `editTime` | `editorTime` | admin.js:461,464,528 |
| `editTags` | `editorTags` | admin.js:467 |
| `editExcerpt` | `editorExcerpt` | admin.js:468 |
| `editContent` | `editorContent` | admin.js:72,469 |
| `editMood` | `hudMood` | admin.js:472 |
| `editEnergy` | `hudEnergy` | admin.js:85,476 |
| `editMotivation` | `hudMotivation` | admin.js:85,477 |
| `editFocus` | `hudFocus` | admin.js:85,478 |
| `editDifficulty` | `hudDifficulty` | admin.js:85,479 |
| `editCoffee` | `hudCoffee` | admin.js:490 |
| `editBPM` | `hudBPM` | admin.js:493 |
| `editThreat` | `hudThreat` | admin.js:496 |
**Fix:** In `admin.js`, rename all `editX``editorX` and `editMood/Energy/etc``hudMood/Energy/etc`, `postId``editorPostId`.
#### 1.1.2 Dashboard Section (8 mismatches)
| admin.js uses | admin.html has | File:Line (JS) |
|---|---|---|
| `dashCPU` | `statCPU` | admin.js:318 |
| `dashMEM` | `statMEM` | admin.js:319 |
| `dashDISK` | `statDISK` | admin.js:320 |
| `dashPosts` | `statPosts` | admin.js:330 |
| `dashWords` | `statWords` | admin.js:331 |
| `dashTracks` | `statTracks` | admin.js:343 |
| `dashServicesGrid` | `servicesGrid` | admin.js:351 |
| `dashThreats` | *(not in HTML)* | admin.js:370 |
**Fix:** In `admin.js`, rename `dashX``statX`, `dashServicesGrid``servicesGrid`. Add a threats container `<div id="dashThreats">` to the dashboard section of `admin.html`, OR rename the JS reference.
#### 1.1.3 API Keys Section (19 mismatches)
| admin.js uses | admin.html has | File:Line (JS) |
|---|---|---|
| `weatherApiKey` | `apiWeatherKey` | admin.js:1294 |
| `spotifyClientId` | `apiSpotifyClientId` | admin.js:1297 |
| `spotifyClientSecret` | `apiSpotifyClientSecret` | admin.js:1298 |
| `spotifyRefreshToken` | `apiSpotifyRefreshToken` | admin.js:1299 |
| `smtpHost` | `apiSmtpHost` | admin.js:1302 |
| `smtpPort` | `apiSmtpPort` | admin.js:1303 |
| `smtpUser` | `apiSmtpUser` | admin.js:1304 |
| `smtpPass` | `apiSmtpPass` | admin.js:1305 |
| `discordWebhook` | `apiDiscordWebhook` | admin.js:1308 |
| `githubToken` | `apiGithubToken` | admin.js:1312 |
| `customApi1Name` | `apiCustom1Name` | admin.js:1315 |
| `customApi1Key` | `apiCustom1Key` | admin.js:1316 |
| `customApi1Url` | `apiCustom1URL` | admin.js:1317 |
| `customApi2Name` | `apiCustom2Name` | admin.js:1320 |
| `customApi2Key` | `apiCustom2Key` | admin.js:1321 |
| `customApi2Url` | `apiCustom2URL` | admin.js:1322 |
| `customApi3Name` | `apiCustom3Name` | admin.js:1325 |
| `customApi3Key` | `apiCustom3Key` | admin.js:1326 |
| `customApi3Url` | `apiCustom3URL` | admin.js:1327 |
**Fix:** In `admin.js`, rename all to match HTML IDs (add `api` prefix, use `URL` not `Url`). OR rename all HTML IDs to match JS convention.
#### 1.1.4 Theme Section (7 mismatches)
| admin.js uses | admin.html has | File:Line (JS) |
|---|---|---|
| `themeAccent` | `themeAccentColor` | admin.js:1351+ |
| `themeAccentHex` | `themeAccentColorHex` | admin.js:1351+ |
| `themeBg` | `themeBgColor` | admin.js:1351+ |
| `themeBgHex` | `themeBgColorHex` | admin.js:1351+ |
| `themeText` | `themeTextColor` | admin.js:1351+ |
| `themeTextHex` | `themeTextColorHex` | admin.js:1351+ |
| `themeGrid` | `themeGridBg` | admin.js:1351+ |
| `valFontSize` | `themeFontSizeVal` | admin.js:1351+ |
**Fix:** In `admin.js`, add `Color` suffix to colour IDs, `themeGrid``themeGridBg`, `valFontSize``themeFontSizeVal`.
#### 1.1.5 Services Section (2 mismatches)
| admin.js uses | admin.html has | File:Line (JS) |
|---|---|---|
| `managedServicesList` | `servicesList` | admin.js:1009 |
| `serviceUrl` | `serviceURL` | admin.js:1034 |
**Fix:** `managedServicesList``servicesList`, `serviceUrl``serviceURL`.
#### 1.1.6 Navigation Section (2 mismatches)
| admin.js uses | admin.html has | File:Line (JS) |
|---|---|---|
| `navItemsList` | `navList` | admin.js:1089 |
| `navUrl` | `navURL` | admin.js:1118 |
**Fix:** `navItemsList``navList`, `navUrl``navURL`.
#### 1.1.7 Links Section (2 mismatches)
| admin.js uses | admin.html has | File:Line (JS) |
|---|---|---|
| `managedLinksList` | `linksList` | admin.js:1175 |
| `linkUrl` | `linkURL` | admin.js:1201 |
**Fix:** `managedLinksList``linksList`, `linkUrl``linkURL`.
#### 1.1.8 Other Mismatches (4)
| admin.js uses | admin.html has | File:Line (JS) |
|---|---|---|
| `loginForm` | *(no form element)* | admin.js:29 |
| `notifications` | `notification` | admin.js:259 |
| `trackUrl` | `trackURL` | admin.js:826+ |
| *(none)* | `editorWordCount` (JS-created #bpmValue in post.js) | — |
**Fix:** Either add `<form id="loginForm">` wrapper in HTML or change JS to use `loginBtn` click handler only. `notifications``notification`, `trackUrl``trackURL`.
---
### 1.2 ❌ CRITICAL: 5 Undefined onclick Methods
**Impact:** Clicking Save buttons in 5 admin sections throws `AdminApp.X is not a function` error.
| Method Called (admin.html) | Correct Method in admin.js | HTML Line |
|---|---|---|
| `AdminApp.saveServices()` | *(does not exist)* | admin.html:608 |
| `AdminApp.saveNavigation()` | *(does not exist)* | admin.html:643 |
| `AdminApp.saveLinks()` | *(does not exist)* | admin.html:684 |
| `AdminApp.saveSEO()` | *(does not exist)* | admin.html:961 |
| `AdminApp.saveContact()` | `AdminApp.saveContactSettings()` | admin.html:996 |
**Fix for saveContact:** Change `admin.html:996` from `AdminApp.saveContact()` to `AdminApp.saveContactSettings()`.
**Fix for saveServices/saveNavigation/saveLinks/saveSEO:** These methods need to be implemented in `admin.js`. Currently, the admin.js CRUD for services/nav/links works per-item (add/delete) but has no bulk "Save" method. Either:
- (a) Remove the Save buttons from HTML (since add/delete already persist via API), OR
- (b) Add PUT methods in admin.js that POST the full array
For saveSEO, admin.js has `saveSeo()` (lowercase 'eo'). Change `admin.html:961` to `AdminApp.saveSeo()`.
---
### 1.3 ❌ CRITICAL: API Payload Shape Mismatches (Frontend ↔ Backend)
**Impact:** POST requests from admin panel silently fail or corrupt data.
#### 1.3.1 Services/Navigation/Links — Array vs Single Object
- **admin.js `addService()`** (line 1047): Sends `JSON.stringify(this.servicesData)` — the **full array**
- **app.py `add_managed_service()`** (line 342): Does `d = request.get_json(); svcs.append({name: d.get('name')})` — expects a **single object**
- Result: Flask receives an array, calls `.get('name')` on it → returns `None`, appends `{name: '', url: ''}` empty entry
Same pattern for:
- `addNavItem()` (admin.js:1129) vs `add_navigation()` (app.py:374)
- `addLink()` (admin.js:1213) vs `add_link()` (app.py:409)
**Fix:** Change admin.js to send only the new item:
```javascript
// In addService():
body: JSON.stringify({ name, url }) // NOT this.servicesData
```
#### 1.3.2 API Keys — Flat vs Nested Payload
- **admin.js `saveApiKey()`** (line 1334): Sends `{ group: 'weather', weather_api_key: 'xxx' }` — flat object with group field
- **app.py `save_apikeys()`** (line 474-476): Expects `{ group: 'weather', data: { api_key: 'xxx' } }` — nested with `data` dict
- Result: `data = d.get('data', {})` returns empty dict → nothing saved
**Fix:** Change admin.js `saveApiKey()` to wrap fields in a `data` object:
```javascript
const payload = { group };
const data = {};
switch (group) {
case 'weather': data.api_key = this.getVal('weatherApiKey'); break;
// ...
}
payload.data = data;
```
---
### 1.4 ❌ CRITICAL: /api/services Endpoint Hangs (Timeout)
**File:** `api/app.py:181-200`
**Impact:** Dashboard never finishes loading. Nginx logs show `upstream timed out (110: Connection timed out)`. The admin dashboard calls this at load time via `loadDashboard()`.
**Root Cause:** The `services()` function makes synchronous HTTP requests to 7 external URLs (git.jaeswift.xyz, plex.jaeswift.xyz, etc.) from inside the Flask app. The VPS cannot resolve its own subdomains from localhost — `curl -sk https://git.jaeswift.xyz` returns code 000 (DNS/connection failure) with 3+ second timeout per service. Total: 7 × 5s timeout = 35s, exceeding nginx's default 60s proxy timeout.
**Fix:**
1. Add `/etc/hosts` entries on VPS: `127.0.0.1 git.jaeswift.xyz plex.jaeswift.xyz archive.jaeswift.xyz`
2. OR reduce timeout in app.py: `req.get(s['url'], timeout=2, ...)`
3. OR use `concurrent.futures.ThreadPoolExecutor` to check all services in parallel
4. OR move service checks to a background task/cache
---
### 1.5 ❌ CRITICAL: /api/git-activity Endpoint Hangs
**File:** `api/app.py:260-282`
**Impact:** Same DNS issue as /api/services. Requests to `https://git.jaeswift.xyz/api/v1/users/jae/heatmap` fail from VPS localhost.
**Fix:** Same as 1.4 — add hosts entries or reduce timeouts.
---
### 1.6 ❌ CRITICAL: 51 CSS Classes Missing from admin.css
**Impact:** Major layout breakage in admin panel — sidebar, login screen, backups, settings, notifications, and many components render unstyled.
**Missing classes referenced in admin.html but not defined in admin.css OR style.css:**
| Class | Used In | Category |
|---|---|---|
| `.sidebar` | Layout wrapper | Layout |
| `.sidebar-brand` | Brand logo area | Layout |
| `.sidebar-close` | Mobile close btn | Layout |
| `.sidebar-divider` | Section dividers | Layout |
| `.sidebar-header` | Header area | Layout |
| `.sidebar-nav` | Nav container | Layout |
| `.sidebar-section-label` | Group labels | Layout |
| `.sidebar-version` | Version footer | Layout |
| `.topbar` | Top bar | Layout |
| `.topbar-title` | Title text | Layout |
| `.topbar-logout` | Logout btn | Layout |
| `.login-screen` | Login wrapper | Auth |
| `.login-form` | Form container | Auth |
| `.login-logo` | Logo area | Auth |
| `.notification` | Toast container | UI |
| `.operator-hud` | Editor HUD | Editor |
| `.hud-grid` | HUD metrics grid | Editor |
| `.hud-range` | Range sliders | Editor |
| `.hud-val` | Value labels | Editor |
| `.editor-split` | Split view | Editor |
| `.editor-pane` | Editor pane | Editor |
| `.preview-pane` | Preview pane | Editor |
| `.editor-meta-bar` | Meta info bar | Editor |
| `.editor-textarea-sm` | Small textareas | Editor |
| `.section-toolbar` | Toolbar | Editor |
| `.toolbar-btn` | Toolbar buttons | Editor |
| `.toolbar-sep` | Toolbar separator | Editor |
| `.admin-table` | Posts table | Posts |
| `.table-wrap` | Table wrapper | Posts |
| `.tracks-add-form` | Add track form | Tracks |
| `.tracks-list` | Tracks container | Tracks |
| `.settings-grid` | Settings layout | Settings |
| `.setting-item` | Setting row | Settings |
| `.toggle-slider` | Toggle control | Settings |
| `.homepage-section-name` | Section label | Homepage |
| `.homepage-section-controls` | Controls | Homepage |
| `.services-manage-list` | Services list | Services |
| `.nav-manage-list` | Nav items list | Navigation |
| `.links-manage-list` | Links list | Links |
| `.apikey-group` | Key group card | API Keys |
| `.color-input` | Colour picker | Theme |
| `.color-hex` | Hex input | Theme |
| `.sm` | Small variant | Utility |
| `.lg` | Large variant | Utility |
| `.backup-card-icon` | Card icon | Backups |
| `.backup-card-title` | Card title | Backups |
| `.backup-card-desc` | Card description | Backups |
| `.backup-desc` | Description text | Backups |
| `.backup-export` | Export section | Backups |
| `.backup-status` | Status area | Backups |
| `.threat-feed` | Threats container | Dashboard |
**Fix:** These classes must be defined in `admin.css`. Many were apparently planned (the HTML uses them consistently) but never written.
---
### 1.7 ❌ CRITICAL: Stale Root-Level JS Files on VPS
**Impact:** Potential confusion; the root-level `/var/www/jaeswift-homepage/admin.js` (66,567 bytes) is an OLDER version than `/var/www/jaeswift-homepage/js/admin.js` (66,766 bytes). The diff shows the root copy is missing the `showSection()` normalization fix.
**Diff:**
```diff
# Root admin.js is MISSING these 3 lines present in js/admin.js:
+ // Normalize: ensure name has section- prefix
+ if (!name.startsWith('section-')) name = 'section-' + name;
# Root admin.js has outdated sidebar active-link selector:
- const activeLink = document.querySelector(`[onclick*="'${name}'"]`);
# js/admin.js has fixed version:
+ const shortName = name.replace('section-', '');
+ const activeLink = document.querySelector(`.sidebar-link[data-section="${shortName}"]`);
```
**Affected root files (permissions also wrong — 600 vs 644):**
| File | Root Size | js/ Size | Match? |
|---|---|---|---|
| admin.js | 66,567 (perms: 600) | 66,766 (perms: 644) | ❌ DIFFERENT |
| blog.js | 7,325 (perms: 600) | 7,325 (perms: 644) | ✅ Same |
| main.js | 36,092 (perms: 600) | 36,092 (perms: 644) | ✅ Same |
| post.js | 13,473 (perms: 600) | 13,473 (perms: 644) | ✅ Same |
**Fix:** Delete the stale root-level JS files:
```bash
rm /var/www/jaeswift-homepage/admin.js /var/www/jaeswift-homepage/blog.js \
/var/www/jaeswift-homepage/main.js /var/www/jaeswift-homepage/post.js
```
---
### 1.8 ❌ CRITICAL: showSection() Broken in Root admin.js
**File:** Root `/var/www/jaeswift-homepage/admin.js` (stale copy)
**Impact:** If somehow the root copy were served (e.g., nginx `try_files` fallback), the sidebar section switching would not work because the old code lacks the `section-` prefix normalization.
Note: Currently nginx serves from `/js/admin.js` (the correct file), so this is only a risk if the stale file is served by accident. Still, it should be cleaned up.
**Fix:** Delete root-level JS files (see 1.7).
---
## 2. HIGH ISSUES
### 2.1 ⚠️ HIGH: blog.html and post.html — 4 CSS Classes Undefined
**Impact:** Navbar, clock, and signal animation don't render correctly on blog pages.
| Class | File | Not in |
|---|---|---|
| `.navbar` | blog.html, post.html | blog.css, style.css |
| `.nav-clock` | blog.html, post.html | blog.css, style.css |
| `.signal-flicker` | blog.html, post.html | blog.css, style.css |
| `.blog-header-content` | blog.html only | blog.css, style.css |
**Fix:** Define these classes in `blog.css` or `style.css`. The index.html uses different class names for the navbar (likely defined in style.css under different names).
### 2.2 ⚠️ HIGH: No CORS Headers on API
**Impact:** If any external/subdomain frontend tries to call the API, requests will be blocked.
**Test result:** `curl -sI /api/posts | grep access-control` returned nothing.
**Fix:** Add Flask-CORS or manual headers:
```python
from flask_cors import CORS
CORS(app, resources={r"/api/*": {"origins": "https://jaeswift.xyz"}})
```
### 2.3 ⚠️ HIGH: Auth Endpoints Return HTML Error Pages
**File:** `api/app.py:41-52` (`require_auth` decorator)
**Impact:** Admin.js expects JSON responses but gets HTML `<title>401 Unauthorized</title>`.
**Evidence:**
```
curl http://127.0.0.1:5000/api/apikeys →
<!doctype html><title>401 Unauthorized</title><h1>Unauthorized</h1><p>Missing token</p>
```
**Fix:** The `require_auth` decorator should use `abort()` with JSON, or Flask should be configured for JSON error responses:
```python
@app.errorhandler(401)
def unauthorized(e):
return jsonify({'error': 'Unauthorized', 'message': str(e)}), 401
```
### 2.4 ⚠️ HIGH: /api/login Returns 404 (Route is /api/auth/login)
**Impact:** If anything calls `/api/login` (the intuitive URL), it 404s.
**Analysis:** Flask route is `@app.route('/api/auth/login')` (line 62). admin.js correctly uses `this.API + '/auth/login'` (line 132). No functional issue currently, but the non-standard auth path could confuse future integrations.
**Status:** Working correctly but unconventional. Consider adding an alias route.
### 2.5 ⚠️ HIGH: admin.js Sends Full Array on Add Operations
**File:** `js/admin.js:1044-1048` (addService), `1129` (addNavItem), `1213` (addLink)
**Impact:** Even though the POST creates a garbage entry (see 1.3.1), the admin.js then calls `loadX()` which re-fetches from the API, showing the corrupted data.
**Root Cause:** The JS pattern is:
1. Push new item to local array: `this.servicesData.push({name, url})`
2. Send ENTIRE array to POST endpoint: `body: JSON.stringify(this.servicesData)`
3. Flask `add_managed_service()` treats the array as a single object, calling `.get('name')` on a list → empty entry appended
**Fix:** Send only the new item:
```javascript
body: JSON.stringify({ name, url })
```
### 2.6 ⚠️ HIGH: admin.js saveApiKey() Sends Wrong Payload Shape
**File:** `js/admin.js:1289-1334`
**Impact:** API keys are never saved. The Flask endpoint returns 400 `{"error": "Invalid request: need group and data"}`.
**Current JS payload:** `{ group: 'weather', weather_api_key: 'xxx' }`
**Expected by Flask:** `{ group: 'weather', data: { api_key: 'xxx' } }`
**Fix:** Restructure the `saveApiKey()` method to wrap fields in `data`:
```javascript
async saveApiKey(group) {
const data = {};
switch (group) {
case 'weather':
data.api_key = this.getVal('apiWeatherKey'); // also fix ID
break;
// etc.
}
const res = await fetch(this.API + '/apikeys', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify({ group, data })
});
}
```
### 2.7 ⚠️ HIGH: dashThreats Container Missing from admin.html
**File:** `admin.html` (dashboard section, ~line 126-167)
**Impact:** `admin.js:370` tries `document.getElementById('dashThreats')` — returns null, threats never display on dashboard.
**Fix:** Add to dashboard section in admin.html:
```html
<h3 class="section-subheading">▲ THREAT FEED</h3>
<div id="dashThreats" class="threat-feed"></div>
```
### 2.8 ⚠️ HIGH: /api/services Causes Dashboard Load Cascade Failure
**File:** `js/admin.js:308-312`
**Impact:** `loadDashboard()` calls `Promise.all([stats, posts, tracks, services, threats])`. When `/api/services` hangs for 35+ seconds, the entire dashboard appears frozen even though other data loaded fine.
**Fix:** Use `Promise.allSettled()` instead of `Promise.all()`, and handle each result independently:
```javascript
const results = await Promise.allSettled([
fetch(this.API + '/stats', ...),
fetch(this.API + '/posts', ...),
// ...
]);
results.forEach((result, i) => {
if (result.status === 'fulfilled') { /* process */ }
else { console.warn('Dashboard fetch failed:', result.reason); }
});
```
### 2.9 ⚠️ HIGH: nginx Warning — Duplicate MIME Type
**File:** `/etc/nginx/sites-enabled/cat.jaeswift.xyz:19`
**Impact:** Minor — duplicate `text/html` MIME type definition. No functional impact but generates warnings on every `nginx -t` and reload.
**Fix:** Remove duplicate `text/html` from the types block in `cat.jaeswift.xyz` config.
### 2.10 ⚠️ HIGH: nginx Warning — Protocol Options Redefined
**File:** `/etc/nginx/sites-enabled/jaeswift.xyz:26`
**Impact:** SSL protocol options redefined for `[::]:443`. First server block to define wins; subsequent definitions are ignored.
**Fix:** Move SSL protocol directives to `http {}` block in `/etc/nginx/nginx.conf` or use `ssl_protocols` only once across all server blocks sharing the same listen address.
### 2.11 ⚠️ HIGH: Flask Serves with Debug/Development Server
**File:** VPS systemd service runs `python3 /var/www/jaeswift-homepage/api/app.py`
**Impact:** Flask development server is single-threaded, not suitable for production. A single slow `/api/services` request blocks ALL other API requests.
**Fix:** Use gunicorn or waitress:
```bash
# In systemd service:
ExecStart=/usr/bin/gunicorn -w 4 -b 127.0.0.1:5000 app:app
```
### 2.12 ⚠️ HIGH: Flask Makes Unverified HTTPS Requests
**File:** `api/app.py:195,263,267``verify=False`
**Impact:** Suppressed SSL certificate verification for requests to git.jaeswift.xyz and plex.jaeswift.xyz. Generates `InsecureRequestWarning` in logs.
**Fix:** Either install proper CA certs or use `verify='/path/to/cert.pem'`.
---
## 3. MEDIUM ISSUES
### 3.1 🔶 MEDIUM: post.js Creates #bpmValue Dynamically
**File:** `js/post.js:153-154,171`
**Impact:** No current bug — post.js creates the element via innerHTML (line 153) then references it (line 171). Works because innerHTML is synchronous. However, static analysis flags this as a missing ID because post.html contains no such element.
**Note:** Not a bug, but the pattern is fragile. If the innerHTML template changes, the getElementById will silently break.
### 3.2 🔶 MEDIUM: admin.html Has No `<form>` Tag for Login
**File:** `admin.html:16-31`
**Impact:** `admin.js:29` looks for `loginForm` by ID and finds null. The fallback (line 33-35) uses `loginBtn` click handler, which works. But Enter key on username field doesn't submit — only works on password field (line 38-41).
**Fix:** Wrap login fields in `<form id="loginForm">` with `onsubmit="return false;"`.
### 3.3 🔶 MEDIUM: admin.js Notification Container Mismatch
**File:** `js/admin.js:259`, `admin.html:1045`
**Impact:** JS looks for `#notifications` (plural), HTML has `#notification` (singular). Fallback `|| document.body` means notifications append to body — they'll appear but may not be styled correctly.
**Fix:** Change JS to use `notification` (matching HTML) or rename HTML ID.
### 3.4 🔶 MEDIUM: blog.html Nav Links Point to Index Anchors
**File:** `blog.html` navbar
**Impact:** Links like `/#development`, `/#links`, `/#contact` navigate away from blog page back to index. This is intentional for cross-page navigation but there's no way back to blog from those sections.
**Fix:** Consider adding a "BLOG" link in index.html navbar (currently only an anchor `#blog` section exists).
### 3.5 🔶 MEDIUM: index.html Has `href="#"` Links
**File:** `index.html` — various social/placeholder links
**Impact:** Clicking scrolls to page top, poor UX.
**Fix:** Replace `href="#"` with actual URLs or `javascript:void(0)`.
### 3.6 🔶 MEDIUM: No favicon Referenced
**File:** All HTML files
**Impact:** Browser shows default icon, 404 in network tab for `/favicon.ico`.
**Fix:** Add `<link rel="icon" href="/assets/favicon.ico">` to all pages.
### 3.7 🔶 MEDIUM: admin.css is 41KB / admin.js is 66KB
**Impact:** Large unminified files on every admin page load.
**Fix:** Consider minification for production deployment.
### 3.8 🔶 MEDIUM: No Cache-Busting for Static Assets
**File:** All HTML files reference `/js/main.js`, `/css/style.css` etc. with no query string version.
**Impact:** Browser caching may serve stale JS/CSS after deployments.
**Fix:** Add version query strings: `/js/main.js?v=20260401`.
### 3.9 🔶 MEDIUM: app.py `/api/stats` Calls `shell()` for System Stats
**File:** `api/app.py:134` (via `shell()` function at line 54)
**Impact:** Executes shell commands (`uptime`, `df`, `free`, etc.) on every stats request. Not a security issue per se (no user input), but slow and could be cached.
**Fix:** Cache stats with a TTL (e.g., 30 seconds).
### 3.10 🔶 MEDIUM: No Rate Limiting on Login Endpoint
**File:** `api/app.py:62`
**Impact:** Brute force attacks possible against `/api/auth/login`.
**Fix:** Add Flask-Limiter: `@limiter.limit("5/minute")`.
### 3.11 🔶 MEDIUM: JWT Secret Hardcoded
**File:** `api/app.py` (JWT_SECRET referenced)
**Impact:** Secret is in source code committed to git. Anyone with repo access can forge tokens.
**Fix:** Move to environment variable: `JWT_SECRET = os.environ.get('JWT_SECRET', 'fallback')`.
### 3.12 🔶 MEDIUM: No Error Boundary in admin.js
**Impact:** Any unhandled JS error in one section breaks the entire admin panel. No global error handler.
**Fix:** Add:
```javascript
window.addEventListener('unhandledrejection', (e) => {
console.error('Unhandled promise:', e.reason);
AdminApp.notify('An error occurred: ' + e.reason?.message, 'error');
});
```
### 3.13 🔶 MEDIUM: admin.html Editor Toolbar References HUD IDs
**File:** `admin.html:275-345` (Operator HUD section)
**Impact:** The range slider event listeners in `admin.js:85` reference `editEnergy/editMotivation/editFocus/editDifficulty` but HTML has `hudEnergy/hudMotivation/hudFocus/hudDifficulty`. The slider value display labels won't update live.
**Fix:** Part of the broader ID mismatch (1.1.1).
### 3.14 🔶 MEDIUM: blog.html and post.html Missing `<meta charset>`
**Impact:** Character encoding not explicitly set. Unicode characters (used extensively in cyberpunk UI) may render incorrectly in edge cases.
**Fix:** Add `<meta charset="UTF-8">` to `<head>` of both files.
### 3.15 🔶 MEDIUM: Google Fonts Loaded Without `font-display: swap`
**File:** All HTML files
**Impact:** Text invisible during font load (FOIT — Flash of Invisible Text).
**Fix:** Append `&display=swap` to Google Fonts URL (already present in some files, verify all).
---
## 4. LOW ISSUES
### 4.1 LOW: Root-Level JS Files Have Wrong Permissions (600)
**File:** `/var/www/jaeswift-homepage/*.js`
**Impact:** Files are owner-read-only. nginx runs as www-data and cannot read them. Not currently an issue because HTML references `/js/*.js` (the correct copies with 644), but if try_files falls through, these would be served as 403.
**Fix:** `chmod 644 /var/www/jaeswift-homepage/*.js` or delete them (preferred — see 1.7).
### 4.2 LOW: nginx Ghost Blog Catch-All Comment but No Block
**File:** `/etc/nginx/sites-enabled/jaeswift.xyz` — line with `# Ghost Blog (catch-all)` comment
**Impact:** Vestigial comment from previous Ghost setup. No functional issue.
**Fix:** Remove the comment for cleanliness.
### 4.3 LOW: admin.html Uses `javascript:void(0)` hrefs
**File:** `admin.html` sidebar links
**Impact:** Minor accessibility concern — screen readers announce these as links.
**Fix:** Use `<button>` elements instead of `<a>` tags for non-navigation actions.
### 4.4 LOW: No `<meta viewport>` in admin.html
**Impact:** Admin panel may not render correctly on mobile without viewport meta tag.
**Fix:** Add `<meta name="viewport" content="width=device-width, initial-scale=1.0">` (check if already present).
### 4.5 LOW: InsecureRequestWarning Spam in Flask Logs
**File:** Flask service journal
**Impact:** Log noise from `verify=False` in requests to git/plex subdomains.
**Fix:** Add `import urllib3; urllib3.disable_warnings()` or fix the SSL verification.
### 4.6 LOW: admin.css Missing from Local Repo
**File:** `/a0/usr/workdir/jaeswift-website/css/admin.css`
**Impact:** The local copy exists but VPS copy has different content (VPS: 41,541 bytes). Ensure git is the source of truth.
**Fix:** Sync local and VPS copies via git.
### 4.7 LOW: posts.json Contains 9 Posts but No Pagination
**Impact:** As posts grow, `/api/posts` returns all posts in a single response. Currently fine with 9 posts.
**Fix:** Add optional `?page=1&limit=10` query parameters to the posts endpoint.
---
## 5. WHAT'S WORKING CORRECTLY ✅
| Component | Status |
|---|---|
| nginx → static HTML serving | ✅ All pages return 200 |
| All 9 blog post slug URLs | ✅ `/blog/post/<slug>` resolves correctly |
| Flask API on port 5000 | ✅ Active and running |
| `/api/posts` GET | ✅ Returns 9 posts with valid JSON |
| `/api/stats` GET | ✅ Returns server stats |
| `/api/weather` GET | ✅ Returns weather data |
| `/api/nowplaying` GET | ✅ Returns now playing info |
| `/api/tracks` GET | ✅ Returns 35 tracks |
| `/api/threats` GET | ✅ Returns CVE data |
| `/api/settings` GET | ✅ Returns settings |
| `/api/homepage` GET | ✅ Returns homepage config |
| `/api/navigation` GET | ✅ Returns 3 nav items |
| `/api/links` GET | ✅ Returns 2 links |
| `/api/theme` GET | ✅ Returns theme config |
| `/api/seo` GET | ✅ Returns SEO config |
| `/api/services/managed` GET | ✅ Returns 3 managed services |
| `/api/auth/login` POST | ✅ Returns JWT token |
| Auth protection on endpoints | ✅ Returns 401 without token |
| All 12 JSON data files | ✅ Valid JSON, correct types |
| CSS files (style/blog/post) | ✅ All load with 200 |
| JS files (main/blog/post) | ✅ All load with 200 |
| main.js ↔ index.html IDs | ✅ All IDs match |
| blog.js ↔ blog.html IDs | ✅ All IDs match |
| SSL certificates | ✅ Valid Let's Encrypt |
| nginx config syntax | ✅ Test passes (with warnings) |
| Blog clean URLs | ✅ `/blog/post/.+` → post.html |
| try_files fallback | ✅ `$uri $uri.html $uri/` |
---
## 6. PRIORITY FIX ORDER
1. **Fix 60 DOM ID mismatches in admin.js** (or admin.html) — this alone fixes ~80% of admin issues
2. **Add 51 missing CSS classes to admin.css** — makes admin panel visually functional
3. **Fix 5 undefined onclick methods** — either implement or rename to match existing
4. **Fix API payload mismatches** — services/nav/links send single object, apikeys wrap in data
5. **Fix /api/services timeout** — add /etc/hosts entries or parallelize with timeout
6. **Delete stale root-level JS files on VPS**
7. **Add CORS headers to Flask API**
8. **Return JSON error responses from Flask (not HTML)**
9. **Switch to gunicorn for production**
10. **Add missing CSS classes for blog.html/post.html navbar**
---
*End of Audit Report*

View file

@ -162,7 +162,7 @@
<!-- Threat Feed --> <!-- Threat Feed -->
<h3 class="section-subheading">⚠ THREAT FEED</h3> <h3 class="section-subheading">⚠ THREAT FEED</h3>
<div id="threatFeed" class="threat-feed"> <div id="dashThreats" class="threat-feed">
<!-- Populated by JS --> <!-- Populated by JS -->
</div> </div>
</div> </div>
@ -605,7 +605,7 @@
</div> </div>
<div class="editor-actions"> <div class="editor-actions">
<button class="action-btn accent" onclick="AdminApp.saveServices()">⬡ SAVE SERVICES</button> <!-- Save button removed: add/delete persist via API -->
</div> </div>
</div> </div>
@ -640,7 +640,7 @@
</div> </div>
<div class="editor-actions"> <div class="editor-actions">
<button class="action-btn accent" onclick="AdminApp.saveNavigation()">⬡ SAVE NAVIGATION</button> <!-- Save button removed: add/delete persist via API -->
</div> </div>
</div> </div>
@ -681,7 +681,7 @@
</div> </div>
<div class="editor-actions"> <div class="editor-actions">
<button class="action-btn accent" onclick="AdminApp.saveLinks()">⬡ SAVE LINKS</button> <!-- Save button removed: add/delete persist via API -->
</div> </div>
</div> </div>
@ -958,7 +958,7 @@
</div> </div>
<div class="editor-actions"> <div class="editor-actions">
<button class="action-btn accent" onclick="AdminApp.saveSEO()">⬡ SAVE SEO</button> <button class="action-btn accent" onclick="AdminApp.saveSeo()">⬡ SAVE SEO</button>
</div> </div>
</div> </div>
@ -993,7 +993,7 @@
</div> </div>
<div class="editor-actions"> <div class="editor-actions">
<button class="action-btn accent" onclick="AdminApp.saveContact()">⬡ SAVE CONTACT</button> <button class="action-btn accent" onclick="AdminApp.saveContactSettings()">⬡ SAVE CONTACT</button>
</div> </div>
</div> </div>

View file

@ -2,6 +2,7 @@
"""JAESWIFT HUD Backend API""" """JAESWIFT HUD Backend API"""
import json, os, time, subprocess, random, datetime, hashlib, zipfile, io, smtplib import json, os, time, subprocess, random, datetime, hashlib, zipfile, io, smtplib
from functools import wraps from functools import wraps
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path from pathlib import Path
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
@ -10,6 +11,8 @@ from flask import Flask, request, jsonify, abort, send_file
from flask_cors import CORS from flask_cors import CORS
import jwt import jwt
import requests as req import requests as req
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
@ -23,6 +26,23 @@ ARRAY_FILES = {
'posts.json', 'tracks.json', 'navigation.json', 'links.json', 'posts.json', 'tracks.json', 'navigation.json', 'links.json',
'managed_services.json', 'messages.json' 'managed_services.json', 'messages.json'
} }
# ─── JSON Error Handlers ─────────────────────────────
@app.errorhandler(400)
def bad_request(e):
return jsonify({'error': 'Bad Request', 'message': str(e.description) if hasattr(e, 'description') else str(e)}), 400
@app.errorhandler(401)
def unauthorized(e):
return jsonify({'error': 'Unauthorized', 'message': str(e.description) if hasattr(e, 'description') else str(e)}), 401
@app.errorhandler(404)
def not_found(e):
return jsonify({'error': 'Not Found', 'message': str(e.description) if hasattr(e, 'description') else str(e)}), 404
@app.errorhandler(500)
def server_error(e):
return jsonify({'error': 'Internal Server Error', 'message': str(e.description) if hasattr(e, 'description') else str(e)}), 500
# ─── Helpers ───────────────────────────────────────── # ─── Helpers ─────────────────────────────────────────
def load_json(name): def load_json(name):
@ -188,15 +208,20 @@ def services():
{'name': 'Agent Zero', 'url': 'https://agentzero.jaeswift.xyz'}, {'name': 'Agent Zero', 'url': 'https://agentzero.jaeswift.xyz'},
{'name': 'Files', 'url': 'https://files.jaeswift.xyz'}, {'name': 'Files', 'url': 'https://files.jaeswift.xyz'},
] ]
results = [] def check_service(s):
for s in svcs:
try: try:
t0 = time.time() t0 = time.time()
r = req.get(s['url'], timeout=5, verify=False, allow_redirects=True) r = req.get(s['url'], timeout=2, verify=False, allow_redirects=True)
ms = round((time.time() - t0) * 1000) ms = round((time.time() - t0) * 1000)
results.append({**s, 'status': 'online' if r.status_code < 500 else 'offline', 'response_time_ms': ms}) return {**s, 'status': 'online' if r.status_code < 500 else 'offline', 'response_time_ms': ms}
except Exception: except Exception:
results.append({**s, 'status': 'offline', 'response_time_ms': 0}) return {**s, 'status': 'offline', 'response_time_ms': 0}
results = [None] * len(svcs)
with ThreadPoolExecutor(max_workers=7) as executor:
futures = {executor.submit(check_service, s): i for i, s in enumerate(svcs)}
for future in as_completed(futures):
results[futures[future]] = future.result()
return jsonify(results) return jsonify(results)
# ─── Weather ───────────────────────────────────────── # ─── Weather ─────────────────────────────────────────
@ -261,11 +286,11 @@ def delete_track(index):
def git_activity(): def git_activity():
try: try:
r = req.get('https://git.jaeswift.xyz/api/v1/users/jae/heatmap', r = req.get('https://git.jaeswift.xyz/api/v1/users/jae/heatmap',
timeout=5, verify=False) timeout=2, verify=False)
heatmap = r.json() if r.status_code == 200 else [] heatmap = r.json() if r.status_code == 200 else []
r2 = req.get('https://git.jaeswift.xyz/api/v1/repos/search?sort=updated&limit=5&owner=jae', r2 = req.get('https://git.jaeswift.xyz/api/v1/repos/search?sort=updated&limit=5&owner=jae',
timeout=5, verify=False) timeout=2, verify=False)
repos = [] repos = []
if r2.status_code == 200: if r2.status_code == 200:
data = r2.json().get('data', r2.json()) if isinstance(r2.json(), dict) else r2.json() data = r2.json().get('data', r2.json()) if isinstance(r2.json(), dict) else r2.json()

View file

@ -1980,3 +1980,505 @@ body {
.gap-sm { gap: 0.4rem; } .gap-sm { gap: 0.4rem; }
.gap-md { gap: 0.8rem; } .gap-md { gap: 0.8rem; }
.gap-lg { gap: 1.2rem; } .gap-lg { gap: 1.2rem; }
/* ===================================================
MISSING CLASSES Added by audit fix 2026-04-01
=================================================== */
/* ─── LAYOUT: Sidebar ─── */
.sidebar {
position: fixed;
top: 0;
left: 0;
width: 240px;
height: 100vh;
background: #060a12;
border-right: 1px solid rgba(0,255,200,0.1);
display: flex;
flex-direction: column;
z-index: 1000;
transition: transform 0.3s ease;
overflow-y: auto;
}
.sidebar-brand {
padding: 1.2rem 1rem;
font-size: 1.1rem;
font-weight: 700;
color: #00ffc8;
letter-spacing: 2px;
text-transform: uppercase;
border-bottom: 1px solid rgba(0,255,200,0.08);
display: flex;
align-items: center;
gap: 0.5rem;
}
.sidebar-close {
display: none;
position: absolute;
top: 0.8rem;
right: 0.8rem;
background: none;
border: 1px solid rgba(0,255,200,0.2);
color: #00ffc8;
font-size: 1.2rem;
cursor: pointer;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.sidebar-divider {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0,255,200,0.15), transparent);
margin: 0.5rem 0;
}
.sidebar-header {
padding: 0.6rem 1rem 0.3rem;
font-size: 0.6rem;
color: rgba(0,255,200,0.4);
text-transform: uppercase;
letter-spacing: 3px;
}
.sidebar-nav {
flex: 1;
padding: 0.5rem 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-section-label {
padding: 0.8rem 1rem 0.3rem;
font-size: 0.55rem;
color: rgba(0,255,200,0.35);
text-transform: uppercase;
letter-spacing: 3px;
font-weight: 600;
}
.sidebar-version {
padding: 0.8rem 1rem;
font-size: 0.6rem;
color: rgba(255,255,255,0.2);
border-top: 1px solid rgba(0,255,200,0.06);
text-align: center;
}
/* ─── LAYOUT: Topbar ─── */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.8rem 1.5rem;
background: rgba(6,10,18,0.9);
border-bottom: 1px solid rgba(0,255,200,0.08);
backdrop-filter: blur(10px);
position: sticky;
top: 0;
z-index: 100;
}
.topbar-title {
font-size: 0.75rem;
color: rgba(0,255,200,0.6);
text-transform: uppercase;
letter-spacing: 2px;
font-weight: 600;
}
.topbar-logout {
background: none;
border: 1px solid rgba(255,50,50,0.3);
color: #ff3232;
padding: 0.4rem 1rem;
font-size: 0.65rem;
cursor: pointer;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.topbar-logout:hover {
background: rgba(255,50,50,0.1);
border-color: #ff3232;
}
/* ─── AUTH: Login ─── */
.login-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #060a12;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.login-logo {
text-align: center;
margin-bottom: 1.5rem;
font-size: 1.8rem;
color: #00ffc8;
text-shadow: 0 0 20px rgba(0,255,200,0.3);
letter-spacing: 4px;
font-weight: 700;
}
/* ─── UI: Notification ─── */
.notification {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.notification > * {
pointer-events: auto;
}
/* ─── EDITOR: Operator HUD ─── */
.operator-hud {
background: rgba(0,255,200,0.03);
border: 1px solid rgba(0,255,200,0.1);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
}
.hud-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.8rem;
margin-top: 0.8rem;
}
.hud-range {
width: 100%;
accent-color: #00ffc8;
height: 4px;
cursor: pointer;
}
.hud-val {
display: inline-block;
min-width: 24px;
text-align: center;
color: #00ffc8;
font-weight: 700;
font-size: 0.9rem;
font-family: "JetBrains Mono", monospace;
}
/* ─── EDITOR: Split view ─── */
.editor-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
min-height: 400px;
}
.editor-pane {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.preview-pane {
background: rgba(0,0,0,0.3);
border: 1px solid rgba(0,255,200,0.08);
border-radius: 6px;
padding: 1rem;
overflow-y: auto;
max-height: 600px;
color: #e0e0e0;
font-size: 0.85rem;
line-height: 1.6;
}
.editor-meta-bar {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
align-items: end;
padding: 0.8rem 0;
border-bottom: 1px solid rgba(0,255,200,0.06);
margin-bottom: 0.8rem;
}
.editor-textarea-sm {
width: 100%;
min-height: 60px;
resize: vertical;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(0,255,200,0.12);
border-radius: 4px;
color: #e0e0e0;
padding: 0.5rem;
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
}
/* ─── EDITOR: Toolbar ─── */
.section-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
padding: 0.5rem;
background: rgba(0,0,0,0.2);
border: 1px solid rgba(0,255,200,0.06);
border-radius: 6px;
margin-bottom: 0.8rem;
}
.toolbar-btn {
background: rgba(0,255,200,0.06);
border: 1px solid rgba(0,255,200,0.12);
color: #00ffc8;
padding: 0.3rem 0.6rem;
font-size: 0.7rem;
cursor: pointer;
border-radius: 3px;
transition: all 0.2s;
font-family: "JetBrains Mono", monospace;
}
.toolbar-btn:hover {
background: rgba(0,255,200,0.15);
border-color: #00ffc8;
}
.toolbar-sep {
width: 1px;
height: 20px;
background: rgba(0,255,200,0.12);
margin: 0 0.2rem;
}
/* ─── POSTS: Table ─── */
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}
.admin-table th,
.admin-table td {
padding: 0.6rem 0.8rem;
text-align: left;
border-bottom: 1px solid rgba(0,255,200,0.06);
}
.admin-table th {
color: rgba(0,255,200,0.5);
text-transform: uppercase;
letter-spacing: 1px;
font-size: 0.6rem;
font-weight: 600;
}
.admin-table tr:hover {
background: rgba(0,255,200,0.03);
}
.table-wrap {
overflow-x: auto;
border: 1px solid rgba(0,255,200,0.08);
border-radius: 6px;
background: rgba(0,0,0,0.15);
}
/* ─── TRACKS ─── */
.tracks-add-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.8rem;
padding: 1rem;
background: rgba(0,255,200,0.02);
border: 1px solid rgba(0,255,200,0.08);
border-radius: 6px;
margin-bottom: 1rem;
}
.tracks-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
/* ─── SETTINGS ─── */
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.8rem 1rem;
background: rgba(0,0,0,0.2);
border: 1px solid rgba(0,255,200,0.06);
border-radius: 6px;
gap: 1rem;
}
.toggle-slider {
position: relative;
width: 42px;
height: 22px;
background: rgba(255,255,255,0.1);
border-radius: 11px;
cursor: pointer;
transition: background 0.3s;
flex-shrink: 0;
}
.toggle-slider::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: #888;
border-radius: 50%;
transition: all 0.3s;
}
.toggle-slider.active {
background: rgba(0,255,200,0.2);
}
.toggle-slider.active::after {
left: 22px;
background: #00ffc8;
}
/* ─── HOMEPAGE ─── */
.homepage-section-name {
font-size: 0.7rem;
color: #00ffc8;
text-transform: uppercase;
letter-spacing: 2px;
font-weight: 600;
}
.homepage-section-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* ─── SERVICES / NAV / LINKS manage lists ─── */
.services-manage-list,
.nav-manage-list,
.links-manage-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.8rem;
}
.services-manage-list > div,
.nav-manage-list > div,
.links-manage-list > div {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.8rem;
background: rgba(0,0,0,0.2);
border: 1px solid rgba(0,255,200,0.06);
border-radius: 4px;
font-size: 0.75rem;
}
/* ─── API KEYS ─── */
.apikey-group {
background: rgba(0,0,0,0.2);
border: 1px solid rgba(0,255,200,0.08);
border-radius: 8px;
padding: 1.2rem;
margin-bottom: 1rem;
}
/* ─── THEME ─── */
.color-input {
width: 50px;
height: 36px;
border: 1px solid rgba(0,255,200,0.15);
border-radius: 4px;
cursor: pointer;
background: transparent;
padding: 2px;
}
.color-input::-webkit-color-swatch-wrapper { padding: 0; }
.color-input::-webkit-color-swatch { border: none; border-radius: 2px; }
.color-hex {
width: 90px;
font-family: "JetBrains Mono", monospace;
font-size: 0.75rem;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(0,255,200,0.12);
border-radius: 4px;
color: #e0e0e0;
padding: 0.4rem 0.5rem;
text-align: center;
}
/* ─── UTILITY: Size variants ─── */
.sm { font-size: 0.75rem; }
.lg { font-size: 1.1rem; }
/* ─── BACKUPS ─── */
.backup-card-icon {
font-size: 1.8rem;
margin-bottom: 0.5rem;
}
.backup-card-title {
font-size: 0.8rem;
font-weight: 700;
color: #00ffc8;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.3rem;
}
.backup-card-desc {
font-size: 0.65rem;
color: rgba(255,255,255,0.4);
line-height: 1.4;
}
.backup-desc {
font-size: 0.7rem;
color: rgba(255,255,255,0.5);
padding: 0.8rem 0;
line-height: 1.5;
}
.backup-export {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 0.8rem;
}
.backup-status {
padding: 0.8rem;
background: rgba(0,255,200,0.03);
border: 1px solid rgba(0,255,200,0.08);
border-radius: 6px;
font-size: 0.7rem;
color: rgba(255,255,255,0.5);
margin-top: 0.8rem;
}
/* ─── DASHBOARD: Threat Feed ─── */
.threat-feed {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.5rem;
}
.threat-feed .threat-empty {
padding: 1rem;
text-align: center;
color: #00ffc8;
font-size: 0.75rem;
opacity: 0.6;
}
/* ─── RESPONSIVE: Sidebar mobile ─── */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-close {
display: block;
}
.editor-split {
grid-template-columns: 1fr;
}
.hud-grid {
grid-template-columns: 1fr 1fr;
}
}

View file

@ -63,13 +63,13 @@ const AdminApp = {
} }
// Editor: auto-slug on title input // Editor: auto-slug on title input
const editTitle = document.getElementById('editTitle'); const editTitle = document.getElementById('editorTitle');
if (editTitle) { if (editTitle) {
editTitle.addEventListener('input', () => this.autoSlug()); editTitle.addEventListener('input', () => this.autoSlug());
} }
// Editor: live preview on content input // Editor: live preview on content input
const editContent = document.getElementById('editContent'); const editContent = document.getElementById('editorContent');
if (editContent) { if (editContent) {
editContent.addEventListener('input', () => this.updatePreview()); editContent.addEventListener('input', () => this.updatePreview());
} }
@ -82,10 +82,10 @@ const AdminApp = {
}); });
// Operator HUD sliders // Operator HUD sliders
['editEnergy', 'editMotivation', 'editFocus', 'editDifficulty'].forEach(id => { ['hudEnergy', 'hudMotivation', 'hudFocus', 'hudDifficulty'].forEach(id => {
const slider = document.getElementById(id); const slider = document.getElementById(id);
if (slider) { if (slider) {
const valId = 'val' + id.replace('edit', ''); const valId = 'val' + id.replace('hud', '');
const valEl = document.getElementById(valId); const valEl = document.getElementById(valId);
if (valEl) valEl.textContent = slider.value; if (valEl) valEl.textContent = slider.value;
slider.addEventListener('input', () => { slider.addEventListener('input', () => {
@ -95,7 +95,7 @@ const AdminApp = {
}); });
// Theme colour sync // Theme colour sync
['Accent', 'Bg', 'Text'].forEach(name => { ['AccentColor', 'BgColor', 'TextColor'].forEach(name => {
const picker = document.getElementById('theme' + name); const picker = document.getElementById('theme' + name);
const hex = document.getElementById('theme' + name + 'Hex'); const hex = document.getElementById('theme' + name + 'Hex');
if (picker && hex) { if (picker && hex) {
@ -112,7 +112,7 @@ const AdminApp = {
// Theme font size slider // Theme font size slider
const fontSlider = document.getElementById('themeFontSize'); const fontSlider = document.getElementById('themeFontSize');
const valFont = document.getElementById('valFontSize'); const valFont = document.getElementById('themeFontSizeVal');
if (fontSlider && valFont) { if (fontSlider && valFont) {
valFont.textContent = fontSlider.value; valFont.textContent = fontSlider.value;
fontSlider.addEventListener('input', () => { fontSlider.addEventListener('input', () => {
@ -256,7 +256,7 @@ const AdminApp = {
/* ─────────────────── NOTIFICATIONS ─────────────────── */ /* ─────────────────── NOTIFICATIONS ─────────────────── */
notify(msg, type = 'success') { notify(msg, type = 'success') {
const container = document.getElementById('notifications') || document.body; const container = document.getElementById('notification') || document.body;
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'notification notification-' + type; div.className = 'notification notification-' + type;
div.innerHTML = `<span class="notification-icon">${type === 'success' ? '✓' : type === 'error' ? '✗' : ''}</span><span>${msg}</span>`; div.innerHTML = `<span class="notification-icon">${type === 'success' ? '✓' : type === 'error' ? '✗' : ''}</span><span>${msg}</span>`;
@ -303,52 +303,58 @@ const AdminApp = {
/* ─────────────────── DASHBOARD ─────────────────── */ /* ─────────────────── DASHBOARD ─────────────────── */
async loadDashboard() { async loadDashboard() {
try { const results = await Promise.allSettled([
const [statsRes, postsRes, tracksRes, servicesRes, threatsRes] = await Promise.all([ fetch(this.API + '/stats', { headers: this.authHeaders() }),
fetch(this.API + '/stats', { headers: this.authHeaders() }), fetch(this.API + '/posts', { headers: this.authHeaders() }),
fetch(this.API + '/posts', { headers: this.authHeaders() }), fetch(this.API + '/tracks', { headers: this.authHeaders() }),
fetch(this.API + '/tracks', { headers: this.authHeaders() }), fetch(this.API + '/services', { headers: this.authHeaders() }),
fetch(this.API + '/services', { headers: this.authHeaders() }), fetch(this.API + '/threats', { headers: this.authHeaders() })
fetch(this.API + '/threats', { headers: this.authHeaders() }) ]);
]);
// Stats // Stats
if (statsRes.ok) { try {
const stats = await statsRes.json(); if (results[0].status === 'fulfilled' && results[0].value.ok) {
const cpu = document.getElementById('dashCPU'); const stats = await results[0].value.json();
const mem = document.getElementById('dashMEM'); const cpu = document.getElementById('statCPU');
const disk = document.getElementById('dashDISK'); const mem = document.getElementById('statMEM');
const disk = document.getElementById('statDISK');
if (cpu) cpu.textContent = (stats.cpu_percent || stats.cpu || 0) + '%'; if (cpu) cpu.textContent = (stats.cpu_percent || stats.cpu || 0) + '%';
if (mem) mem.textContent = (stats.memory_percent || stats.memory || 0) + '%'; if (mem) mem.textContent = (stats.memory_percent || stats.memory || 0) + '%';
if (disk) disk.textContent = (stats.disk_percent || stats.disk || 0) + '%'; if (disk) disk.textContent = (stats.disk_percent || stats.disk || 0) + '%';
} }
} catch (e) { console.warn('Dashboard stats error:', e); }
// Posts count + word count // Posts count + word count
if (postsRes.ok) { try {
const posts = await postsRes.json(); 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 arr = Array.isArray(posts) ? posts : (posts.posts || []);
const dashPosts = document.getElementById('dashPosts'); const dashPosts = document.getElementById('statPosts');
const dashWords = document.getElementById('dashWords'); const dashWords = document.getElementById('statWords');
if (dashPosts) dashPosts.textContent = arr.length; if (dashPosts) dashPosts.textContent = arr.length;
if (dashWords) { if (dashWords) {
const totalWords = arr.reduce((sum, p) => sum + (p.word_count || 0), 0); const totalWords = arr.reduce((sum, p) => sum + (p.word_count || 0), 0);
dashWords.textContent = totalWords.toLocaleString(); dashWords.textContent = totalWords.toLocaleString();
} }
} }
} catch (e) { console.warn('Dashboard posts error:', e); }
// Tracks count // Tracks count
if (tracksRes.ok) { try {
const tracks = await tracksRes.json(); 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 arr = Array.isArray(tracks) ? tracks : (tracks.tracks || []);
const dashTracks = document.getElementById('dashTracks'); const dashTracks = document.getElementById('statTracks');
if (dashTracks) dashTracks.textContent = arr.length; if (dashTracks) dashTracks.textContent = arr.length;
} }
} catch (e) { console.warn('Dashboard tracks error:', e); }
// Services grid // Services grid
if (servicesRes.ok) { try {
const services = await servicesRes.json(); 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 arr = Array.isArray(services) ? services : (services.services || []);
const grid = document.getElementById('dashServicesGrid'); const grid = document.getElementById('servicesGrid');
if (grid) { if (grid) {
grid.innerHTML = arr.map(svc => { grid.innerHTML = arr.map(svc => {
const isUp = svc.status === 'up' || svc.status === 'online' || svc.status === true; const isUp = svc.status === 'up' || svc.status === 'online' || svc.status === true;
@ -361,16 +367,21 @@ const AdminApp = {
</div>`; </div>`;
}).join(''); }).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 // Threats
if (threatsRes.ok) { try {
const threats = await threatsRes.json(); 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 arr = Array.isArray(threats) ? threats : (threats.threats || threats.items || []);
const container = document.getElementById('dashThreats'); const container = document.getElementById('dashThreats');
if (container) { if (container) {
if (arr.length === 0) { if (arr.length === 0) {
container.innerHTML = '<div class="threat-empty">No active threats detected </div>'; container.innerHTML = '<div class="threat-empty">No active threats detected \u2713</div>';
} else { } else {
container.innerHTML = arr.map(t => { container.innerHTML = arr.map(t => {
const severity = (t.severity || t.level || 'low').toLowerCase(); const severity = (t.severity || t.level || 'low').toLowerCase();
@ -386,10 +397,7 @@ const AdminApp = {
} }
} }
} }
} catch (e) { console.warn('Dashboard threats error:', e); }
} catch (err) {
console.error('Dashboard load error:', err);
}
}, },
/* ─────────────────── POSTS ─────────────────── */ /* ─────────────────── POSTS ─────────────────── */
@ -448,35 +456,35 @@ const AdminApp = {
const heading = document.getElementById('editorTitle'); const heading = document.getElementById('editorTitle');
if (heading) heading.textContent = 'EDIT POST'; if (heading) heading.textContent = 'EDIT POST';
document.getElementById('postId').value = post.slug || slug; document.getElementById('editorPostId').value = post.slug || slug;
document.getElementById('editTitle').value = post.title || ''; document.getElementById('editorTitle').value = post.title || '';
document.getElementById('editSlug').value = post.slug || ''; document.getElementById('editorSlug').value = post.slug || '';
// Parse date and time // Parse date and time
if (post.date) { if (post.date) {
const dt = new Date(post.date); const dt = new Date(post.date);
const dateStr = dt.toISOString().split('T')[0]; const dateStr = dt.toISOString().split('T')[0];
const timeStr = dt.toTimeString().slice(0, 5); const timeStr = dt.toTimeString().slice(0, 5);
document.getElementById('editDate').value = dateStr; document.getElementById('editorDate').value = dateStr;
document.getElementById('editTime').value = timeStr; document.getElementById('editorTime').value = timeStr;
} else { } else {
document.getElementById('editDate').value = ''; document.getElementById('editorDate').value = '';
document.getElementById('editTime').value = ''; document.getElementById('editorTime').value = '';
} }
document.getElementById('editTags').value = Array.isArray(post.tags) ? post.tags.join(', ') : (post.tags || ''); document.getElementById('editorTags').value = Array.isArray(post.tags) ? post.tags.join(', ') : (post.tags || '');
document.getElementById('editExcerpt').value = post.excerpt || ''; document.getElementById('editorExcerpt').value = post.excerpt || '';
document.getElementById('editContent').value = post.content || ''; document.getElementById('editorContent').value = post.content || '';
// Operator HUD fields // Operator HUD fields
const moodEl = document.getElementById('editMood'); const moodEl = document.getElementById('hudMood');
if (moodEl) moodEl.value = post.mood || 'focused'; if (moodEl) moodEl.value = post.mood || 'focused';
const hudFields = [ const hudFields = [
{ id: 'editEnergy', key: 'energy', valId: 'valEnergy' }, { id: 'hudEnergy', key: 'energy', valId: 'valEnergy' },
{ id: 'editMotivation', key: 'motivation', valId: 'valMotivation' }, { id: 'hudMotivation', key: 'motivation', valId: 'valMotivation' },
{ id: 'editFocus', key: 'focus', valId: 'valFocus' }, { id: 'hudFocus', key: 'focus', valId: 'valFocus' },
{ id: 'editDifficulty', key: 'difficulty', valId: 'valDifficulty' } { id: 'hudDifficulty', key: 'difficulty', valId: 'valDifficulty' }
]; ];
hudFields.forEach(f => { hudFields.forEach(f => {
const el = document.getElementById(f.id); const el = document.getElementById(f.id);
@ -487,13 +495,13 @@ const AdminApp = {
} }
}); });
const coffeeEl = document.getElementById('editCoffee'); const coffeeEl = document.getElementById('hudCoffee');
if (coffeeEl) coffeeEl.value = post.coffee || 0; if (coffeeEl) coffeeEl.value = post.coffee || 0;
const bpmEl = document.getElementById('editBPM'); const bpmEl = document.getElementById('hudBPM');
if (bpmEl) bpmEl.value = post.bpm || 0; if (bpmEl) bpmEl.value = post.bpm || 0;
const threatEl = document.getElementById('editThreat'); const threatEl = document.getElementById('hudThreat');
if (threatEl) threatEl.value = post.threat_level || 'low'; if (threatEl) threatEl.value = post.threat_level || 'low';
this.updatePreview(); this.updatePreview();
@ -521,14 +529,14 @@ const AdminApp = {
}, },
async savePost() { async savePost() {
const postId = document.getElementById('postId').value; const postId = document.getElementById('editorPostId').value;
const title = document.getElementById('editTitle').value.trim(); const title = document.getElementById('editorTitle').value.trim();
const slug = document.getElementById('editSlug').value.trim(); const slug = document.getElementById('editorSlug').value.trim();
const date = document.getElementById('editDate').value; const date = document.getElementById('editorDate').value;
const time = document.getElementById('editTime').value || '00:00'; const time = document.getElementById('editorTime').value || '00:00';
const tags = document.getElementById('editTags').value; const tags = document.getElementById('editorTags').value;
const excerpt = document.getElementById('editExcerpt').value.trim(); const excerpt = document.getElementById('editorExcerpt').value.trim();
const content = document.getElementById('editContent').value; const content = document.getElementById('editorContent').value;
if (!title) { this.notify('Title is required', 'error'); return; } if (!title) { this.notify('Title is required', 'error'); return; }
if (!slug) { this.notify('Slug is required', 'error'); return; } if (!slug) { this.notify('Slug is required', 'error'); return; }
@ -544,14 +552,14 @@ const AdminApp = {
const tagList = tags.split(',').map(t => t.trim()).filter(t => t.length > 0); const tagList = tags.split(',').map(t => t.trim()).filter(t => t.length > 0);
// Operator HUD // Operator HUD
const mood = document.getElementById('editMood')?.value || 'focused'; const mood = document.getElementById('hudMood')?.value || 'focused';
const energy = parseInt(document.getElementById('editEnergy')?.value || 3); const energy = parseInt(document.getElementById('hudEnergy')?.value || 3);
const motivation = parseInt(document.getElementById('editMotivation')?.value || 3); const motivation = parseInt(document.getElementById('hudMotivation')?.value || 3);
const focus = parseInt(document.getElementById('editFocus')?.value || 3); const focus = parseInt(document.getElementById('hudFocus')?.value || 3);
const difficulty = parseInt(document.getElementById('editDifficulty')?.value || 3); const difficulty = parseInt(document.getElementById('hudDifficulty')?.value || 3);
const coffee = parseInt(document.getElementById('editCoffee')?.value || 0); const coffee = parseInt(document.getElementById('hudCoffee')?.value || 0);
const bpm = parseInt(document.getElementById('editBPM')?.value || 0); const bpm = parseInt(document.getElementById('hudBPM')?.value || 0);
const threatLevel = document.getElementById('editThreat')?.value || 'low'; const threatLevel = document.getElementById('hudThreat')?.value || 'low';
const payload = { const payload = {
title, slug, date: datetime, tags: tagList, excerpt, content, title, slug, date: datetime, tags: tagList, excerpt, content,
@ -587,37 +595,37 @@ const AdminApp = {
}, },
clearEditor() { clearEditor() {
document.getElementById('postId').value = ''; document.getElementById('editorPostId').value = '';
document.getElementById('editTitle').value = ''; document.getElementById('editorTitle').value = '';
document.getElementById('editSlug').value = ''; document.getElementById('editorSlug').value = '';
document.getElementById('editDate').value = new Date().toISOString().split('T')[0]; document.getElementById('editorDate').value = new Date().toISOString().split('T')[0];
document.getElementById('editTime').value = new Date().toTimeString().slice(0, 5); document.getElementById('editorTime').value = new Date().toTimeString().slice(0, 5);
document.getElementById('editTags').value = ''; document.getElementById('editorTags').value = '';
document.getElementById('editExcerpt').value = ''; document.getElementById('editorExcerpt').value = '';
document.getElementById('editContent').value = ''; document.getElementById('editorContent').value = '';
const preview = document.getElementById('editorPreview'); const preview = document.getElementById('editorPreview');
if (preview) preview.innerHTML = ''; if (preview) preview.innerHTML = '';
// Reset HUD // Reset HUD
const moodEl = document.getElementById('editMood'); const moodEl = document.getElementById('hudMood');
if (moodEl) moodEl.value = 'focused'; if (moodEl) moodEl.value = 'focused';
['editEnergy', 'editMotivation', 'editFocus', 'editDifficulty'].forEach(id => { ['hudEnergy', 'hudMotivation', 'hudFocus', 'hudDifficulty'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) { if (el) {
el.value = 3; el.value = 3;
const valId = 'val' + id.replace('edit', ''); const valId = 'val' + id.replace('hud', '');
const valEl = document.getElementById(valId); const valEl = document.getElementById(valId);
if (valEl) valEl.textContent = '3'; if (valEl) valEl.textContent = '3';
} }
}); });
const coffeeEl = document.getElementById('editCoffee'); const coffeeEl = document.getElementById('hudCoffee');
if (coffeeEl) coffeeEl.value = 0; if (coffeeEl) coffeeEl.value = 0;
const bpmEl = document.getElementById('editBPM'); const bpmEl = document.getElementById('hudBPM');
if (bpmEl) bpmEl.value = 80; if (bpmEl) bpmEl.value = 80;
const threatEl = document.getElementById('editThreat'); const threatEl = document.getElementById('hudThreat');
if (threatEl) threatEl.value = 'low'; if (threatEl) threatEl.value = 'low';
const heading = document.getElementById('editorTitle'); const heading = document.getElementById('editorTitle');
@ -627,7 +635,7 @@ const AdminApp = {
/* ─────────────────── EDITOR TOOLBAR ─────────────────── */ /* ─────────────────── EDITOR TOOLBAR ─────────────────── */
insertMarkdown(action) { insertMarkdown(action) {
const textarea = document.getElementById('editContent'); const textarea = document.getElementById('editorContent');
if (!textarea) return; if (!textarea) return;
const start = textarea.selectionStart; const start = textarea.selectionStart;
@ -700,7 +708,7 @@ const AdminApp = {
}, },
updatePreview() { updatePreview() {
const content = document.getElementById('editContent')?.value || ''; const content = document.getElementById('editorContent')?.value || '';
const preview = document.getElementById('editorPreview'); const preview = document.getElementById('editorPreview');
if (!preview) return; if (!preview) return;
@ -758,7 +766,7 @@ const AdminApp = {
}, },
autoSlug() { autoSlug() {
const title = document.getElementById('editTitle')?.value || ''; const title = document.getElementById('editorTitle')?.value || '';
const slug = title const slug = title
.toLowerCase() .toLowerCase()
.trim() .trim()
@ -766,7 +774,7 @@ const AdminApp = {
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/-+/g, '-') .replace(/-+/g, '-')
.replace(/^-|-$/g, ''); .replace(/^-|-$/g, '');
const slugEl = document.getElementById('editSlug'); const slugEl = document.getElementById('editorSlug');
if (slugEl) slugEl.value = slug; if (slugEl) slugEl.value = slug;
}, },
@ -812,7 +820,7 @@ const AdminApp = {
const artist = document.getElementById('trackArtist')?.value.trim(); const artist = document.getElementById('trackArtist')?.value.trim();
const album = document.getElementById('trackAlbum')?.value.trim(); const album = document.getElementById('trackAlbum')?.value.trim();
const genre = document.getElementById('trackGenre')?.value.trim(); const genre = document.getElementById('trackGenre')?.value.trim();
const url = document.getElementById('trackUrl')?.value.trim(); const url = document.getElementById('trackURL')?.value.trim();
const cover = document.getElementById('trackCover')?.value.trim(); const cover = document.getElementById('trackCover')?.value.trim();
if (!title || !artist) { if (!title || !artist) {
@ -832,7 +840,7 @@ const AdminApp = {
this.notify('Track added'); this.notify('Track added');
// Clear inputs // Clear inputs
['trackTitle', 'trackArtist', 'trackAlbum', 'trackGenre', 'trackUrl', 'trackCover'].forEach(id => { ['trackTitle', 'trackArtist', 'trackAlbum', 'trackGenre', 'trackURL', 'trackCover'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.value = ''; if (el) el.value = '';
}); });
@ -1006,7 +1014,7 @@ const AdminApp = {
const data = await res.json(); const data = await res.json();
this.servicesData = Array.isArray(data) ? data : (data.services || []); this.servicesData = Array.isArray(data) ? data : (data.services || []);
const container = document.getElementById('managedServicesList'); const container = document.getElementById('servicesList');
if (!container) return; if (!container) return;
if (this.servicesData.length === 0) { if (this.servicesData.length === 0) {
@ -1031,7 +1039,7 @@ const AdminApp = {
async addService() { async addService() {
const name = document.getElementById('serviceName')?.value.trim(); const name = document.getElementById('serviceName')?.value.trim();
const url = document.getElementById('serviceUrl')?.value.trim(); const url = document.getElementById('serviceURL')?.value.trim();
if (!name) { if (!name) {
this.notify('Service name is required', 'error'); this.notify('Service name is required', 'error');
@ -1044,12 +1052,12 @@ const AdminApp = {
const res = await fetch(this.API + '/services/managed', { const res = await fetch(this.API + '/services/managed', {
method: 'POST', method: 'POST',
headers: this.authHeaders(), headers: this.authHeaders(),
body: JSON.stringify(this.servicesData) body: JSON.stringify({ name, url })
}); });
if (!res.ok) throw new Error('Save failed'); if (!res.ok) throw new Error('Save failed');
this.notify('Service added'); this.notify('Service added');
document.getElementById('serviceName').value = ''; document.getElementById('serviceName').value = '';
document.getElementById('serviceUrl').value = ''; document.getElementById('serviceURL').value = '';
this.loadServices(); this.loadServices();
} catch (err) { } catch (err) {
console.error('Add service error:', err); console.error('Add service error:', err);
@ -1066,7 +1074,7 @@ const AdminApp = {
const res = await fetch(this.API + '/services/managed', { const res = await fetch(this.API + '/services/managed', {
method: 'POST', method: 'POST',
headers: this.authHeaders(), headers: this.authHeaders(),
body: JSON.stringify(this.servicesData) body: JSON.stringify({ name, url })
}); });
if (!res.ok) throw new Error('Delete failed'); if (!res.ok) throw new Error('Delete failed');
this.notify('Service deleted'); this.notify('Service deleted');
@ -1086,7 +1094,7 @@ const AdminApp = {
const data = await res.json(); const data = await res.json();
this.navData = Array.isArray(data) ? data : (data.items || []); this.navData = Array.isArray(data) ? data : (data.items || []);
const container = document.getElementById('navItemsList'); const container = document.getElementById('navList');
if (!container) return; if (!container) return;
if (this.navData.length === 0) { if (this.navData.length === 0) {
@ -1115,7 +1123,7 @@ const AdminApp = {
async addNavItem() { async addNavItem() {
const label = document.getElementById('navLabel')?.value.trim(); const label = document.getElementById('navLabel')?.value.trim();
const url = document.getElementById('navUrl')?.value.trim(); const url = document.getElementById('navURL')?.value.trim();
const order = parseInt(document.getElementById('navOrder')?.value || 0); const order = parseInt(document.getElementById('navOrder')?.value || 0);
if (!label || !url) { if (!label || !url) {
@ -1129,12 +1137,12 @@ const AdminApp = {
const res = await fetch(this.API + '/navigation', { const res = await fetch(this.API + '/navigation', {
method: 'POST', method: 'POST',
headers: this.authHeaders(), headers: this.authHeaders(),
body: JSON.stringify(this.navData) body: JSON.stringify({ label, url, order })
}); });
if (!res.ok) throw new Error('Save failed'); if (!res.ok) throw new Error('Save failed');
this.notify('Nav item added'); this.notify('Nav item added');
document.getElementById('navLabel').value = ''; document.getElementById('navLabel').value = '';
document.getElementById('navUrl').value = ''; document.getElementById('navURL').value = '';
document.getElementById('navOrder').value = ''; document.getElementById('navOrder').value = '';
this.loadNavigation(); this.loadNavigation();
} catch (err) { } catch (err) {
@ -1152,7 +1160,7 @@ const AdminApp = {
const res = await fetch(this.API + '/navigation', { const res = await fetch(this.API + '/navigation', {
method: 'POST', method: 'POST',
headers: this.authHeaders(), headers: this.authHeaders(),
body: JSON.stringify(this.navData) body: JSON.stringify({ label, url, order })
}); });
if (!res.ok) throw new Error('Delete failed'); if (!res.ok) throw new Error('Delete failed');
this.notify('Nav item deleted'); this.notify('Nav item deleted');
@ -1172,7 +1180,7 @@ const AdminApp = {
const data = await res.json(); const data = await res.json();
this.linksData = Array.isArray(data) ? data : (data.links || []); this.linksData = Array.isArray(data) ? data : (data.links || []);
const container = document.getElementById('managedLinksList'); const container = document.getElementById('linksList');
if (!container) return; if (!container) return;
if (this.linksData.length === 0) { if (this.linksData.length === 0) {
@ -1198,7 +1206,7 @@ const AdminApp = {
async addLink() { async addLink() {
const name = document.getElementById('linkName')?.value.trim(); const name = document.getElementById('linkName')?.value.trim();
const url = document.getElementById('linkUrl')?.value.trim(); const url = document.getElementById('linkURL')?.value.trim();
const icon = document.getElementById('linkIcon')?.value.trim(); const icon = document.getElementById('linkIcon')?.value.trim();
const category = document.getElementById('linkCategory')?.value.trim(); const category = document.getElementById('linkCategory')?.value.trim();
@ -1213,11 +1221,11 @@ const AdminApp = {
const res = await fetch(this.API + '/links', { const res = await fetch(this.API + '/links', {
method: 'POST', method: 'POST',
headers: this.authHeaders(), headers: this.authHeaders(),
body: JSON.stringify(this.linksData) body: JSON.stringify({ name, url, icon: icon || '🔗', category: category || 'general' })
}); });
if (!res.ok) throw new Error('Save failed'); if (!res.ok) throw new Error('Save failed');
this.notify('Link added'); this.notify('Link added');
['linkName', 'linkUrl', 'linkIcon', 'linkCategory'].forEach(id => { ['linkName', 'linkURL', 'linkIcon', 'linkCategory'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.value = ''; if (el) el.value = '';
}); });
@ -1237,7 +1245,7 @@ const AdminApp = {
const res = await fetch(this.API + '/links', { const res = await fetch(this.API + '/links', {
method: 'POST', method: 'POST',
headers: this.authHeaders(), headers: this.authHeaders(),
body: JSON.stringify(this.linksData) body: JSON.stringify({ name, url, icon: icon || '🔗', category: category || 'general' })
}); });
if (!res.ok) throw new Error('Delete failed'); if (!res.ok) throw new Error('Delete failed');
this.notify('Link deleted'); this.notify('Link deleted');
@ -1257,28 +1265,28 @@ const AdminApp = {
const data = await res.json(); const data = await res.json();
// Weather // Weather
this.setVal('weatherApiKey', data.weather_api_key); this.setVal('apiWeatherKey', data.weather_api_key);
// Spotify // Spotify
this.setVal('spotifyClientId', data.spotify_client_id); this.setVal('apiSpotifyClientId', data.spotify_client_id);
this.setVal('spotifyClientSecret', data.spotify_client_secret); this.setVal('apiSpotifyClientSecret', data.spotify_client_secret);
this.setVal('spotifyRefreshToken', data.spotify_refresh_token); this.setVal('apiSpotifyRefreshToken', data.spotify_refresh_token);
// SMTP // SMTP
this.setVal('smtpHost', data.smtp_host); this.setVal('apiSmtpHost', data.smtp_host);
this.setVal('smtpPort', data.smtp_port); this.setVal('apiSmtpPort', data.smtp_port);
this.setVal('smtpUser', data.smtp_user); this.setVal('apiSmtpUser', data.smtp_user);
this.setVal('smtpPass', data.smtp_pass); this.setVal('apiSmtpPass', data.smtp_pass);
// Discord / GitHub // Discord / GitHub
this.setVal('discordWebhook', data.discord_webhook); this.setVal('apiDiscordWebhook', data.discord_webhook);
this.setVal('githubToken', data.github_token); this.setVal('apiGithubToken', data.github_token);
// Custom APIs // Custom APIs
for (let i = 1; i <= 3; i++) { for (let i = 1; i <= 3; i++) {
this.setVal(`customApi${i}Name`, data[`custom_api_${i}_name`]); this.setVal(`apiCustom${i}Name`, data[`custom_api_${i}_name`]);
this.setVal(`customApi${i}Key`, data[`custom_api_${i}_key`]); this.setVal(`apiCustom${i}Key`, data[`custom_api_${i}_key`]);
this.setVal(`customApi${i}Url`, data[`custom_api_${i}_url`]); this.setVal(`apiCustom${i}URL`, data[`custom_api_${i}_url`]);
} }
} catch (err) { } catch (err) {
console.error('API keys load error:', err); console.error('API keys load error:', err);
@ -1287,43 +1295,43 @@ const AdminApp = {
}, },
async saveApiKey(group) { async saveApiKey(group) {
const payload = { group }; const data = {};
switch (group) { switch (group) {
case 'weather': case 'weather':
payload.weather_api_key = this.getVal('weatherApiKey'); data.api_key = this.getVal('apiWeatherKey');
break; break;
case 'spotify': case 'spotify':
payload.spotify_client_id = this.getVal('spotifyClientId'); data.client_id = this.getVal('apiSpotifyClientId');
payload.spotify_client_secret = this.getVal('spotifyClientSecret'); data.client_secret = this.getVal('apiSpotifyClientSecret');
payload.spotify_refresh_token = this.getVal('spotifyRefreshToken'); data.refresh_token = this.getVal('apiSpotifyRefreshToken');
break; break;
case 'smtp': case 'smtp':
payload.smtp_host = this.getVal('smtpHost'); data.host = this.getVal('apiSmtpHost');
payload.smtp_port = this.getVal('smtpPort'); data.port = this.getVal('apiSmtpPort');
payload.smtp_user = this.getVal('smtpUser'); data.user = this.getVal('apiSmtpUser');
payload.smtp_pass = this.getVal('smtpPass'); data.pass = this.getVal('apiSmtpPass');
break; break;
case 'discord': case 'discord':
payload.discord_webhook = this.getVal('discordWebhook'); data.webhook = this.getVal('apiDiscordWebhook');
break; break;
case 'github': case 'github':
payload.github_token = this.getVal('githubToken'); data.token = this.getVal('apiGithubToken');
break; break;
case 'custom1': case 'custom1':
payload.custom_api_1_name = this.getVal('customApi1Name'); data.name = this.getVal('apiCustom1Name');
payload.custom_api_1_key = this.getVal('customApi1Key'); data.key = this.getVal('apiCustom1Key');
payload.custom_api_1_url = this.getVal('customApi1Url'); data.url = this.getVal('apiCustom1URL');
break; break;
case 'custom2': case 'custom2':
payload.custom_api_2_name = this.getVal('customApi2Name'); data.name = this.getVal('apiCustom2Name');
payload.custom_api_2_key = this.getVal('customApi2Key'); data.key = this.getVal('apiCustom2Key');
payload.custom_api_2_url = this.getVal('customApi2Url'); data.url = this.getVal('apiCustom2URL');
break; break;
case 'custom3': case 'custom3':
payload.custom_api_3_name = this.getVal('customApi3Name'); data.name = this.getVal('apiCustom3Name');
payload.custom_api_3_key = this.getVal('customApi3Key'); data.key = this.getVal('apiCustom3Key');
payload.custom_api_3_url = this.getVal('customApi3Url'); data.url = this.getVal('apiCustom3URL');
break; break;
default: default:
this.notify('Unknown API key group: ' + group, 'error'); this.notify('Unknown API key group: ' + group, 'error');
@ -1334,7 +1342,7 @@ const AdminApp = {
const res = await fetch(this.API + '/apikeys', { const res = await fetch(this.API + '/apikeys', {
method: 'POST', method: 'POST',
headers: this.authHeaders(), headers: this.authHeaders(),
body: JSON.stringify(payload) body: JSON.stringify({ group, data })
}); });
if (!res.ok) throw new Error('Save failed'); if (!res.ok) throw new Error('Save failed');
this.notify('API keys saved (' + group + ')'); this.notify('API keys saved (' + group + ')');
@ -1353,23 +1361,23 @@ const AdminApp = {
const data = await res.json(); const data = await res.json();
// Colours // Colours
this.setVal('themeAccent', data.accent || this.themeDefaults.accent); this.setVal('themeAccentColor', data.accent || this.themeDefaults.accent);
this.setVal('themeAccentHex', data.accent || this.themeDefaults.accent); this.setVal('themeAccentColorHex', data.accent || this.themeDefaults.accent);
this.setVal('themeBg', data.bg || this.themeDefaults.bg); this.setVal('themeBgColor', data.bg || this.themeDefaults.bg);
this.setVal('themeBgHex', data.bg || this.themeDefaults.bg); this.setVal('themeBgColorHex', data.bg || this.themeDefaults.bg);
this.setVal('themeText', data.text || this.themeDefaults.text); this.setVal('themeTextColor', data.text || this.themeDefaults.text);
this.setVal('themeTextHex', data.text || this.themeDefaults.text); this.setVal('themeTextColorHex', data.text || this.themeDefaults.text);
// Effect toggles // Effect toggles
this.setChecked('themeScanlines', data.scanlines !== undefined ? data.scanlines : this.themeDefaults.scanlines); this.setChecked('themeScanlines', data.scanlines !== undefined ? data.scanlines : this.themeDefaults.scanlines);
this.setChecked('themeParticles', data.particles !== undefined ? data.particles : this.themeDefaults.particles); this.setChecked('themeParticles', data.particles !== undefined ? data.particles : this.themeDefaults.particles);
this.setChecked('themeGlitch', data.glitch !== undefined ? data.glitch : this.themeDefaults.glitch); this.setChecked('themeGlitch', data.glitch !== undefined ? data.glitch : this.themeDefaults.glitch);
this.setChecked('themeGrid', data.grid !== undefined ? data.grid : this.themeDefaults.grid); this.setChecked('themeGridBg', data.grid !== undefined ? data.grid : this.themeDefaults.grid);
// Font size // Font size
const fontSize = data.font_size || this.themeDefaults.fontSize; const fontSize = data.font_size || this.themeDefaults.fontSize;
this.setVal('themeFontSize', fontSize); this.setVal('themeFontSize', fontSize);
const valFont = document.getElementById('valFontSize'); const valFont = document.getElementById('themeFontSizeVal');
if (valFont) valFont.textContent = fontSize; if (valFont) valFont.textContent = fontSize;
} catch (err) { } catch (err) {
console.error('Theme load error:', err); console.error('Theme load error:', err);
@ -1379,13 +1387,13 @@ const AdminApp = {
async saveTheme() { async saveTheme() {
const payload = { const payload = {
accent: this.getVal('themeAccentHex') || this.getVal('themeAccent'), accent: this.getVal('themeAccentColorHex') || this.getVal('themeAccentColor'),
bg: this.getVal('themeBgHex') || this.getVal('themeBg'), bg: this.getVal('themeBgColorHex') || this.getVal('themeBgColor'),
text: this.getVal('themeTextHex') || this.getVal('themeText'), text: this.getVal('themeTextColorHex') || this.getVal('themeTextColor'),
scanlines: this.getChecked('themeScanlines'), scanlines: this.getChecked('themeScanlines'),
particles: this.getChecked('themeParticles'), particles: this.getChecked('themeParticles'),
glitch: this.getChecked('themeGlitch'), glitch: this.getChecked('themeGlitch'),
grid: this.getChecked('themeGrid'), grid: this.getChecked('themeGridBg'),
font_size: parseInt(this.getVal('themeFontSize') || 16) font_size: parseInt(this.getVal('themeFontSize') || 16)
}; };
@ -1406,18 +1414,18 @@ const AdminApp = {
resetTheme() { resetTheme() {
if (!confirm('Reset theme to defaults?')) return; if (!confirm('Reset theme to defaults?')) return;
this.setVal('themeAccent', this.themeDefaults.accent); this.setVal('themeAccentColor', this.themeDefaults.accent);
this.setVal('themeAccentHex', this.themeDefaults.accent); this.setVal('themeAccentColorHex', this.themeDefaults.accent);
this.setVal('themeBg', this.themeDefaults.bg); this.setVal('themeBgColor', this.themeDefaults.bg);
this.setVal('themeBgHex', this.themeDefaults.bg); this.setVal('themeBgColorHex', this.themeDefaults.bg);
this.setVal('themeText', this.themeDefaults.text); this.setVal('themeTextColor', this.themeDefaults.text);
this.setVal('themeTextHex', this.themeDefaults.text); this.setVal('themeTextColorHex', this.themeDefaults.text);
this.setChecked('themeScanlines', this.themeDefaults.scanlines); this.setChecked('themeScanlines', this.themeDefaults.scanlines);
this.setChecked('themeParticles', this.themeDefaults.particles); this.setChecked('themeParticles', this.themeDefaults.particles);
this.setChecked('themeGlitch', this.themeDefaults.glitch); this.setChecked('themeGlitch', this.themeDefaults.glitch);
this.setChecked('themeGrid', this.themeDefaults.grid); this.setChecked('themeGridBg', this.themeDefaults.grid);
this.setVal('themeFontSize', this.themeDefaults.fontSize); this.setVal('themeFontSize', this.themeDefaults.fontSize);
const valFont = document.getElementById('valFontSize'); const valFont = document.getElementById('themeFontSizeVal');
if (valFont) valFont.textContent = this.themeDefaults.fontSize; if (valFont) valFont.textContent = this.themeDefaults.fontSize;
this.notify('Theme reset to defaults — save to apply', 'info'); this.notify('Theme reset to defaults — save to apply', 'info');