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 -->
<h3 class="section-subheading">⚠ THREAT FEED</h3>
<div id="threatFeed" class="threat-feed">
<div id="dashThreats" class="threat-feed">
<!-- Populated by JS -->
</div>
</div>
@ -605,7 +605,7 @@
</div>
<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>
@ -640,7 +640,7 @@
</div>
<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>
@ -681,7 +681,7 @@
</div>
<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>
@ -958,7 +958,7 @@
</div>
<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>
@ -993,7 +993,7 @@
</div>
<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>

View file

@ -2,6 +2,7 @@
"""JAESWIFT HUD Backend API"""
import json, os, time, subprocess, random, datetime, hashlib, zipfile, io, smtplib
from functools import wraps
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
@ -10,6 +11,8 @@ from flask import Flask, request, jsonify, abort, send_file
from flask_cors import CORS
import jwt
import requests as req
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
app = Flask(__name__)
CORS(app)
@ -23,6 +26,23 @@ ARRAY_FILES = {
'posts.json', 'tracks.json', 'navigation.json', 'links.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 ─────────────────────────────────────────
def load_json(name):
@ -188,15 +208,20 @@ def services():
{'name': 'Agent Zero', 'url': 'https://agentzero.jaeswift.xyz'},
{'name': 'Files', 'url': 'https://files.jaeswift.xyz'},
]
results = []
for s in svcs:
def check_service(s):
try:
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)
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:
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)
# ─── Weather ─────────────────────────────────────────
@ -261,11 +286,11 @@ def delete_track(index):
def git_activity():
try:
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 []
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 = []
if r2.status_code == 200:
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-md { gap: 0.8rem; }
.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
const editTitle = document.getElementById('editTitle');
const editTitle = document.getElementById('editorTitle');
if (editTitle) {
editTitle.addEventListener('input', () => this.autoSlug());
}
// Editor: live preview on content input
const editContent = document.getElementById('editContent');
const editContent = document.getElementById('editorContent');
if (editContent) {
editContent.addEventListener('input', () => this.updatePreview());
}
@ -82,10 +82,10 @@ const AdminApp = {
});
// Operator HUD sliders
['editEnergy', 'editMotivation', 'editFocus', 'editDifficulty'].forEach(id => {
['hudEnergy', 'hudMotivation', 'hudFocus', 'hudDifficulty'].forEach(id => {
const slider = document.getElementById(id);
if (slider) {
const valId = 'val' + id.replace('edit', '');
const valId = 'val' + id.replace('hud', '');
const valEl = document.getElementById(valId);
if (valEl) valEl.textContent = slider.value;
slider.addEventListener('input', () => {
@ -95,7 +95,7 @@ const AdminApp = {
});
// Theme colour sync
['Accent', 'Bg', 'Text'].forEach(name => {
['AccentColor', 'BgColor', 'TextColor'].forEach(name => {
const picker = document.getElementById('theme' + name);
const hex = document.getElementById('theme' + name + 'Hex');
if (picker && hex) {
@ -112,7 +112,7 @@ const AdminApp = {
// Theme font size slider
const fontSlider = document.getElementById('themeFontSize');
const valFont = document.getElementById('valFontSize');
const valFont = document.getElementById('themeFontSizeVal');
if (fontSlider && valFont) {
valFont.textContent = fontSlider.value;
fontSlider.addEventListener('input', () => {
@ -256,7 +256,7 @@ const AdminApp = {
/* ─────────────────── NOTIFICATIONS ─────────────────── */
notify(msg, type = 'success') {
const container = document.getElementById('notifications') || document.body;
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>`;
@ -303,52 +303,58 @@ const AdminApp = {
/* ─────────────────── DASHBOARD ─────────────────── */
async loadDashboard() {
try {
const [statsRes, postsRes, tracksRes, servicesRes, threatsRes] = await Promise.all([
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() })
]);
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
if (statsRes.ok) {
const stats = await statsRes.json();
const cpu = document.getElementById('dashCPU');
const mem = document.getElementById('dashMEM');
const disk = document.getElementById('dashDISK');
// 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
if (postsRes.ok) {
const posts = await postsRes.json();
// 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('dashPosts');
const dashWords = document.getElementById('dashWords');
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
if (tracksRes.ok) {
const tracks = await tracksRes.json();
// 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('dashTracks');
const dashTracks = document.getElementById('statTracks');
if (dashTracks) dashTracks.textContent = arr.length;
}
} catch (e) { console.warn('Dashboard tracks error:', e); }
// Services grid
if (servicesRes.ok) {
const services = await servicesRes.json();
// 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('dashServicesGrid');
const grid = document.getElementById('servicesGrid');
if (grid) {
grid.innerHTML = arr.map(svc => {
const isUp = svc.status === 'up' || svc.status === 'online' || svc.status === true;
@ -361,16 +367,21 @@ const AdminApp = {
</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
if (threatsRes.ok) {
const threats = await threatsRes.json();
// 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 </div>';
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();
@ -386,10 +397,7 @@ const AdminApp = {
}
}
}
} catch (err) {
console.error('Dashboard load error:', err);
}
} catch (e) { console.warn('Dashboard threats error:', e); }
},
/* ─────────────────── POSTS ─────────────────── */
@ -448,35 +456,35 @@ const AdminApp = {
const heading = document.getElementById('editorTitle');
if (heading) heading.textContent = 'EDIT POST';
document.getElementById('postId').value = post.slug || slug;
document.getElementById('editTitle').value = post.title || '';
document.getElementById('editSlug').value = post.slug || '';
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('editDate').value = dateStr;
document.getElementById('editTime').value = timeStr;
document.getElementById('editorDate').value = dateStr;
document.getElementById('editorTime').value = timeStr;
} else {
document.getElementById('editDate').value = '';
document.getElementById('editTime').value = '';
document.getElementById('editorDate').value = '';
document.getElementById('editorTime').value = '';
}
document.getElementById('editTags').value = Array.isArray(post.tags) ? post.tags.join(', ') : (post.tags || '');
document.getElementById('editExcerpt').value = post.excerpt || '';
document.getElementById('editContent').value = post.content || '';
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('editMood');
const moodEl = document.getElementById('hudMood');
if (moodEl) moodEl.value = post.mood || 'focused';
const hudFields = [
{ id: 'editEnergy', key: 'energy', valId: 'valEnergy' },
{ id: 'editMotivation', key: 'motivation', valId: 'valMotivation' },
{ id: 'editFocus', key: 'focus', valId: 'valFocus' },
{ id: 'editDifficulty', key: 'difficulty', valId: 'valDifficulty' }
{ 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);
@ -487,13 +495,13 @@ const AdminApp = {
}
});
const coffeeEl = document.getElementById('editCoffee');
const coffeeEl = document.getElementById('hudCoffee');
if (coffeeEl) coffeeEl.value = post.coffee || 0;
const bpmEl = document.getElementById('editBPM');
const bpmEl = document.getElementById('hudBPM');
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';
this.updatePreview();
@ -521,14 +529,14 @@ const AdminApp = {
},
async savePost() {
const postId = document.getElementById('postId').value;
const title = document.getElementById('editTitle').value.trim();
const slug = document.getElementById('editSlug').value.trim();
const date = document.getElementById('editDate').value;
const time = document.getElementById('editTime').value || '00:00';
const tags = document.getElementById('editTags').value;
const excerpt = document.getElementById('editExcerpt').value.trim();
const content = document.getElementById('editContent').value;
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; }
@ -544,14 +552,14 @@ const AdminApp = {
const tagList = tags.split(',').map(t => t.trim()).filter(t => t.length > 0);
// Operator HUD
const mood = document.getElementById('editMood')?.value || 'focused';
const energy = parseInt(document.getElementById('editEnergy')?.value || 3);
const motivation = parseInt(document.getElementById('editMotivation')?.value || 3);
const focus = parseInt(document.getElementById('editFocus')?.value || 3);
const difficulty = parseInt(document.getElementById('editDifficulty')?.value || 3);
const coffee = parseInt(document.getElementById('editCoffee')?.value || 0);
const bpm = parseInt(document.getElementById('editBPM')?.value || 0);
const threatLevel = document.getElementById('editThreat')?.value || 'low';
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,
@ -587,37 +595,37 @@ const AdminApp = {
},
clearEditor() {
document.getElementById('postId').value = '';
document.getElementById('editTitle').value = '';
document.getElementById('editSlug').value = '';
document.getElementById('editDate').value = new Date().toISOString().split('T')[0];
document.getElementById('editTime').value = new Date().toTimeString().slice(0, 5);
document.getElementById('editTags').value = '';
document.getElementById('editExcerpt').value = '';
document.getElementById('editContent').value = '';
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('editMood');
const moodEl = document.getElementById('hudMood');
if (moodEl) moodEl.value = 'focused';
['editEnergy', 'editMotivation', 'editFocus', 'editDifficulty'].forEach(id => {
['hudEnergy', 'hudMotivation', 'hudFocus', 'hudDifficulty'].forEach(id => {
const el = document.getElementById(id);
if (el) {
el.value = 3;
const valId = 'val' + id.replace('edit', '');
const valId = 'val' + id.replace('hud', '');
const valEl = document.getElementById(valId);
if (valEl) valEl.textContent = '3';
}
});
const coffeeEl = document.getElementById('editCoffee');
const coffeeEl = document.getElementById('hudCoffee');
if (coffeeEl) coffeeEl.value = 0;
const bpmEl = document.getElementById('editBPM');
const bpmEl = document.getElementById('hudBPM');
if (bpmEl) bpmEl.value = 80;
const threatEl = document.getElementById('editThreat');
const threatEl = document.getElementById('hudThreat');
if (threatEl) threatEl.value = 'low';
const heading = document.getElementById('editorTitle');
@ -627,7 +635,7 @@ const AdminApp = {
/* ─────────────────── EDITOR TOOLBAR ─────────────────── */
insertMarkdown(action) {
const textarea = document.getElementById('editContent');
const textarea = document.getElementById('editorContent');
if (!textarea) return;
const start = textarea.selectionStart;
@ -700,7 +708,7 @@ const AdminApp = {
},
updatePreview() {
const content = document.getElementById('editContent')?.value || '';
const content = document.getElementById('editorContent')?.value || '';
const preview = document.getElementById('editorPreview');
if (!preview) return;
@ -758,7 +766,7 @@ const AdminApp = {
},
autoSlug() {
const title = document.getElementById('editTitle')?.value || '';
const title = document.getElementById('editorTitle')?.value || '';
const slug = title
.toLowerCase()
.trim()
@ -766,7 +774,7 @@ const AdminApp = {
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const slugEl = document.getElementById('editSlug');
const slugEl = document.getElementById('editorSlug');
if (slugEl) slugEl.value = slug;
},
@ -812,7 +820,7 @@ const AdminApp = {
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 url = document.getElementById('trackURL')?.value.trim();
const cover = document.getElementById('trackCover')?.value.trim();
if (!title || !artist) {
@ -832,7 +840,7 @@ const AdminApp = {
this.notify('Track added');
// Clear inputs
['trackTitle', 'trackArtist', 'trackAlbum', 'trackGenre', 'trackUrl', 'trackCover'].forEach(id => {
['trackTitle', 'trackArtist', 'trackAlbum', 'trackGenre', 'trackURL', 'trackCover'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
@ -1006,7 +1014,7 @@ const AdminApp = {
const data = await res.json();
this.servicesData = Array.isArray(data) ? data : (data.services || []);
const container = document.getElementById('managedServicesList');
const container = document.getElementById('servicesList');
if (!container) return;
if (this.servicesData.length === 0) {
@ -1031,7 +1039,7 @@ const AdminApp = {
async addService() {
const name = document.getElementById('serviceName')?.value.trim();
const url = document.getElementById('serviceUrl')?.value.trim();
const url = document.getElementById('serviceURL')?.value.trim();
if (!name) {
this.notify('Service name is required', 'error');
@ -1044,12 +1052,12 @@ const AdminApp = {
const res = await fetch(this.API + '/services/managed', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(this.servicesData)
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 = '';
document.getElementById('serviceURL').value = '';
this.loadServices();
} catch (err) {
console.error('Add service error:', err);
@ -1066,7 +1074,7 @@ const AdminApp = {
const res = await fetch(this.API + '/services/managed', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(this.servicesData)
body: JSON.stringify({ name, url })
});
if (!res.ok) throw new Error('Delete failed');
this.notify('Service deleted');
@ -1086,7 +1094,7 @@ const AdminApp = {
const data = await res.json();
this.navData = Array.isArray(data) ? data : (data.items || []);
const container = document.getElementById('navItemsList');
const container = document.getElementById('navList');
if (!container) return;
if (this.navData.length === 0) {
@ -1115,7 +1123,7 @@ const AdminApp = {
async addNavItem() {
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);
if (!label || !url) {
@ -1129,12 +1137,12 @@ const AdminApp = {
const res = await fetch(this.API + '/navigation', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(this.navData)
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('navURL').value = '';
document.getElementById('navOrder').value = '';
this.loadNavigation();
} catch (err) {
@ -1152,7 +1160,7 @@ const AdminApp = {
const res = await fetch(this.API + '/navigation', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(this.navData)
body: JSON.stringify({ label, url, order })
});
if (!res.ok) throw new Error('Delete failed');
this.notify('Nav item deleted');
@ -1172,7 +1180,7 @@ const AdminApp = {
const data = await res.json();
this.linksData = Array.isArray(data) ? data : (data.links || []);
const container = document.getElementById('managedLinksList');
const container = document.getElementById('linksList');
if (!container) return;
if (this.linksData.length === 0) {
@ -1198,7 +1206,7 @@ const AdminApp = {
async addLink() {
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 category = document.getElementById('linkCategory')?.value.trim();
@ -1213,11 +1221,11 @@ const AdminApp = {
const res = await fetch(this.API + '/links', {
method: 'POST',
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');
this.notify('Link added');
['linkName', 'linkUrl', 'linkIcon', 'linkCategory'].forEach(id => {
['linkName', 'linkURL', 'linkIcon', 'linkCategory'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
@ -1237,7 +1245,7 @@ const AdminApp = {
const res = await fetch(this.API + '/links', {
method: 'POST',
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');
this.notify('Link deleted');
@ -1257,28 +1265,28 @@ const AdminApp = {
const data = await res.json();
// Weather
this.setVal('weatherApiKey', data.weather_api_key);
this.setVal('apiWeatherKey', data.weather_api_key);
// Spotify
this.setVal('spotifyClientId', data.spotify_client_id);
this.setVal('spotifyClientSecret', data.spotify_client_secret);
this.setVal('spotifyRefreshToken', data.spotify_refresh_token);
this.setVal('apiSpotifyClientId', data.spotify_client_id);
this.setVal('apiSpotifyClientSecret', data.spotify_client_secret);
this.setVal('apiSpotifyRefreshToken', data.spotify_refresh_token);
// SMTP
this.setVal('smtpHost', data.smtp_host);
this.setVal('smtpPort', data.smtp_port);
this.setVal('smtpUser', data.smtp_user);
this.setVal('smtpPass', data.smtp_pass);
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('discordWebhook', data.discord_webhook);
this.setVal('githubToken', data.github_token);
this.setVal('apiDiscordWebhook', data.discord_webhook);
this.setVal('apiGithubToken', data.github_token);
// Custom APIs
for (let i = 1; i <= 3; i++) {
this.setVal(`customApi${i}Name`, data[`custom_api_${i}_name`]);
this.setVal(`customApi${i}Key`, data[`custom_api_${i}_key`]);
this.setVal(`customApi${i}Url`, data[`custom_api_${i}_url`]);
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);
@ -1287,43 +1295,43 @@ const AdminApp = {
},
async saveApiKey(group) {
const payload = { group };
const data = {};
switch (group) {
case 'weather':
payload.weather_api_key = this.getVal('weatherApiKey');
data.api_key = this.getVal('apiWeatherKey');
break;
case 'spotify':
payload.spotify_client_id = this.getVal('spotifyClientId');
payload.spotify_client_secret = this.getVal('spotifyClientSecret');
payload.spotify_refresh_token = this.getVal('spotifyRefreshToken');
data.client_id = this.getVal('apiSpotifyClientId');
data.client_secret = this.getVal('apiSpotifyClientSecret');
data.refresh_token = this.getVal('apiSpotifyRefreshToken');
break;
case 'smtp':
payload.smtp_host = this.getVal('smtpHost');
payload.smtp_port = this.getVal('smtpPort');
payload.smtp_user = this.getVal('smtpUser');
payload.smtp_pass = this.getVal('smtpPass');
data.host = this.getVal('apiSmtpHost');
data.port = this.getVal('apiSmtpPort');
data.user = this.getVal('apiSmtpUser');
data.pass = this.getVal('apiSmtpPass');
break;
case 'discord':
payload.discord_webhook = this.getVal('discordWebhook');
data.webhook = this.getVal('apiDiscordWebhook');
break;
case 'github':
payload.github_token = this.getVal('githubToken');
data.token = this.getVal('apiGithubToken');
break;
case 'custom1':
payload.custom_api_1_name = this.getVal('customApi1Name');
payload.custom_api_1_key = this.getVal('customApi1Key');
payload.custom_api_1_url = this.getVal('customApi1Url');
data.name = this.getVal('apiCustom1Name');
data.key = this.getVal('apiCustom1Key');
data.url = this.getVal('apiCustom1URL');
break;
case 'custom2':
payload.custom_api_2_name = this.getVal('customApi2Name');
payload.custom_api_2_key = this.getVal('customApi2Key');
payload.custom_api_2_url = this.getVal('customApi2Url');
data.name = this.getVal('apiCustom2Name');
data.key = this.getVal('apiCustom2Key');
data.url = this.getVal('apiCustom2URL');
break;
case 'custom3':
payload.custom_api_3_name = this.getVal('customApi3Name');
payload.custom_api_3_key = this.getVal('customApi3Key');
payload.custom_api_3_url = this.getVal('customApi3Url');
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');
@ -1334,7 +1342,7 @@ const AdminApp = {
const res = await fetch(this.API + '/apikeys', {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify(payload)
body: JSON.stringify({ group, data })
});
if (!res.ok) throw new Error('Save failed');
this.notify('API keys saved (' + group + ')');
@ -1353,23 +1361,23 @@ const AdminApp = {
const data = await res.json();
// Colours
this.setVal('themeAccent', data.accent || this.themeDefaults.accent);
this.setVal('themeAccentHex', data.accent || this.themeDefaults.accent);
this.setVal('themeBg', data.bg || this.themeDefaults.bg);
this.setVal('themeBgHex', data.bg || this.themeDefaults.bg);
this.setVal('themeText', data.text || this.themeDefaults.text);
this.setVal('themeTextHex', data.text || this.themeDefaults.text);
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('themeGrid', data.grid !== undefined ? data.grid : this.themeDefaults.grid);
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('valFontSize');
const valFont = document.getElementById('themeFontSizeVal');
if (valFont) valFont.textContent = fontSize;
} catch (err) {
console.error('Theme load error:', err);
@ -1379,13 +1387,13 @@ const AdminApp = {
async saveTheme() {
const payload = {
accent: this.getVal('themeAccentHex') || this.getVal('themeAccent'),
bg: this.getVal('themeBgHex') || this.getVal('themeBg'),
text: this.getVal('themeTextHex') || this.getVal('themeText'),
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('themeGrid'),
grid: this.getChecked('themeGridBg'),
font_size: parseInt(this.getVal('themeFontSize') || 16)
};
@ -1406,18 +1414,18 @@ const AdminApp = {
resetTheme() {
if (!confirm('Reset theme to defaults?')) return;
this.setVal('themeAccent', this.themeDefaults.accent);
this.setVal('themeAccentHex', this.themeDefaults.accent);
this.setVal('themeBg', this.themeDefaults.bg);
this.setVal('themeBgHex', this.themeDefaults.bg);
this.setVal('themeText', this.themeDefaults.text);
this.setVal('themeTextHex', this.themeDefaults.text);
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('themeGrid', this.themeDefaults.grid);
this.setChecked('themeGridBg', this.themeDefaults.grid);
this.setVal('themeFontSize', this.themeDefaults.fontSize);
const valFont = document.getElementById('valFontSize');
const valFont = document.getElementById('themeFontSizeVal');
if (valFont) valFont.textContent = this.themeDefaults.fontSize;
this.notify('Theme reset to defaults — save to apply', 'info');