feat: admin panel, tracks CRUD, operator HUD improvements, mood badges

This commit is contained in:
jae 2026-03-31 21:30:17 +00:00
parent 271f933b6e
commit 167bcb15a9
9 changed files with 1835 additions and 6 deletions

314
admin.html Normal file
View file

@ -0,0 +1,314 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JAESWIFT // ADMIN</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/admin.css">
</head>
<body>
<!-- Login Screen -->
<div class="admin-login" id="loginScreen">
<div class="login-box">
<div class="login-header">
<div class="login-icon"></div>
<h1 class="login-title">JAESWIFT</h1>
<span class="login-subtitle">ADMIN // CONTROL PANEL</span>
</div>
<form class="login-form" id="loginForm">
<div class="login-field">
<label class="login-label">OPERATOR ID</label>
<input type="text" id="loginUser" class="login-input" autocomplete="username" placeholder="username" required>
</div>
<div class="login-field">
<label class="login-label">ACCESS KEY</label>
<input type="password" id="loginPass" class="login-input" autocomplete="current-password" placeholder="••••••••" required>
</div>
<div class="login-error" id="loginError"></div>
<button type="submit" class="login-btn">AUTHENTICATE →</button>
</form>
<div class="login-footer">
<span class="login-status">SECURE CHANNEL // AES-256</span>
</div>
</div>
</div>
<!-- Admin Dashboard -->
<div class="admin-app" id="adminApp" style="display:none;">
<!-- Top Bar -->
<header class="admin-topbar">
<div class="topbar-left">
<span class="topbar-logo">⬡ JAESWIFT</span>
<span class="topbar-divider">//</span>
<span class="topbar-section" id="topbarSection">DASHBOARD</span>
</div>
<div class="topbar-right">
<span class="topbar-user" id="topbarUser">jae</span>
<button class="topbar-btn" onclick="AdminApp.logout()">LOGOUT</button>
</div>
</header>
<!-- Sidebar -->
<nav class="admin-sidebar">
<a href="#" class="sidebar-link active" data-section="dashboard" onclick="AdminApp.showSection('dashboard')">
<span class="sidebar-icon"></span> DASHBOARD
</a>
<a href="#" class="sidebar-link" data-section="posts" onclick="AdminApp.showSection('posts')">
<span class="sidebar-icon"></span> POSTS
</a>
<a href="#" class="sidebar-link" data-section="editor" onclick="AdminApp.showSection('editor')">
<span class="sidebar-icon"></span> NEW POST
</a>
<a href="#" class="sidebar-link" data-section="tracks" onclick="AdminApp.showSection('tracks')">
<span class="sidebar-icon"></span> TRACKS
</a>
<a href="#" class="sidebar-link" data-section="settings" onclick="AdminApp.showSection('settings')">
<span class="sidebar-icon"></span> SETTINGS
</a>
<div class="sidebar-footer">
<a href="/" class="sidebar-link">
<span class="sidebar-icon"></span> VIEW SITE
</a>
</div>
</nav>
<!-- Main Content -->
<main class="admin-main">
<!-- Dashboard Section -->
<section class="admin-section" id="section-dashboard">
<h2 class="section-heading">SYSTEM OVERVIEW</h2>
<div class="dash-grid">
<div class="dash-card">
<span class="dash-card-label">TOTAL POSTS</span>
<span class="dash-card-value" id="dashPosts">--</span>
</div>
<div class="dash-card">
<span class="dash-card-label">TOTAL WORDS</span>
<span class="dash-card-value" id="dashWords">--</span>
</div>
<div class="dash-card">
<span class="dash-card-label">TRACKS</span>
<span class="dash-card-value" id="dashTracks">--</span>
</div>
<div class="dash-card">
<span class="dash-card-label">SERVER CPU</span>
<span class="dash-card-value" id="dashCPU">--%</span>
</div>
<div class="dash-card">
<span class="dash-card-label">MEMORY</span>
<span class="dash-card-value" id="dashMem">--%</span>
</div>
<div class="dash-card">
<span class="dash-card-label">DISK</span>
<span class="dash-card-value" id="dashDisk">--%</span>
</div>
</div>
<h3 class="section-subheading">SERVICES STATUS</h3>
<div class="services-grid" id="dashServices">Loading...</div>
<h3 class="section-subheading">RECENT THREATS (CVE)</h3>
<div class="threats-list" id="dashThreats">Loading...</div>
</section>
<!-- Posts Section -->
<section class="admin-section" id="section-posts" style="display:none;">
<div class="section-heading-row">
<h2 class="section-heading">BLOG POSTS</h2>
<button class="action-btn" onclick="AdminApp.showSection('editor')">+ NEW POST</button>
</div>
<div class="posts-table-wrap">
<table class="posts-table">
<thead>
<tr>
<th>DATE</th>
<th>TITLE</th>
<th>TAGS</th>
<th>WORDS</th>
<th>THREAT</th>
<th>ACTIONS</th>
</tr>
</thead>
<tbody id="postsTableBody"></tbody>
</table>
</div>
</section>
<!-- Editor Section -->
<section class="admin-section" id="section-editor" style="display:none;">
<h2 class="section-heading" id="editorHeading">NEW TRANSMISSION</h2>
<form class="editor-form" id="editorForm">
<input type="hidden" id="editSlugOriginal">
<div class="editor-row">
<div class="editor-field full">
<label class="editor-label">TITLE</label>
<input type="text" id="edTitle" class="editor-input" placeholder="Post title..." required>
</div>
</div>
<div class="editor-row">
<div class="editor-field">
<label class="editor-label">SLUG</label>
<input type="text" id="edSlug" class="editor-input" placeholder="auto-generated">
</div>
<div class="editor-field">
<label class="editor-label">DATE</label>
<input type="date" id="edDate" class="editor-input">
</div>
<div class="editor-field">
<label class="editor-label">TIME</label>
<input type="time" id="edTime" class="editor-input" value="00:00">
</div>
</div>
<div class="editor-row">
<div class="editor-field">
<label class="editor-label">TAGS (comma-separated)</label>
<input type="text" id="edTags" class="editor-input" placeholder="security, infrastructure">
</div>
<div class="editor-field">
<label class="editor-label">EXCERPT</label>
<input type="text" id="edExcerpt" class="editor-input" placeholder="Short description...">
</div>
</div>
<!-- Cyberpunk Health Stats -->
<div class="editor-hud-section">
<h3 class="editor-hud-title">◈ OPERATOR STATUS</h3>
<div class="editor-hud-grid">
<div class="hud-field">
<label class="editor-label">MOOD</label>
<select id="edMood" class="editor-input editor-select">
<option value="focused">Focused</option>
<option value="creative">Creative</option>
<option value="productive">Productive</option>
<option value="tired">Tired</option>
<option value="wired">Wired</option>
<option value="chaotic">Chaotic</option>
<option value="locked-in">Locked In</option>
<option value="zen">Zen</option>
</select>
</div>
<div class="hud-field">
<label class="editor-label">ENERGY (1-5)</label>
<input type="range" id="edEnergy" class="editor-range" min="1" max="5" value="3">
<span class="range-val" id="edEnergyVal">3</span>
</div>
<div class="hud-field">
<label class="editor-label">MOTIVATION (1-5)</label>
<input type="range" id="edMotivation" class="editor-range" min="1" max="5" value="3">
<span class="range-val" id="edMotivationVal">3</span>
</div>
<div class="hud-field">
<label class="editor-label">FOCUS (1-5)</label>
<input type="range" id="edFocus" class="editor-range" min="1" max="5" value="3">
<span class="range-val" id="edFocusVal">3</span>
</div>
<div class="hud-field">
<label class="editor-label">DIFFICULTY (1-5)</label>
<input type="range" id="edDifficulty" class="editor-range" min="1" max="5" value="3">
<span class="range-val" id="edDifficultyVal">3</span>
</div>
<div class="hud-field">
<label class="editor-label">COFFEE ☕</label>
<input type="number" id="edCoffee" class="editor-input editor-small" min="0" max="10" value="2">
</div>
<div class="hud-field">
<label class="editor-label">BPM ♥</label>
<input type="number" id="edBPM" class="editor-input editor-small" min="40" max="200" value="72">
</div>
<div class="hud-field">
<label class="editor-label">THREAT LEVEL</label>
<select id="edThreat" class="editor-input editor-select">
<option value="low">LOW</option>
<option value="medium">MEDIUM</option>
<option value="high">HIGH</option>
<option value="critical">CRITICAL</option>
</select>
</div>
</div>
</div>
<!-- Content Editor -->
<div class="editor-field full">
<label class="editor-label">CONTENT (Markdown)</label>
<div class="editor-toolbar">
<button type="button" class="tb-btn" onclick="AdminApp.insertMD('**','**')" title="Bold"><b>B</b></button>
<button type="button" class="tb-btn" onclick="AdminApp.insertMD('*','*')" title="Italic"><i>I</i></button>
<button type="button" class="tb-btn" onclick="AdminApp.insertMD('`','`')" title="Code">&lt;/&gt;</button>
<button type="button" class="tb-btn" onclick="AdminApp.insertMD('\n```\n','\n```\n')" title="Code Block">[ ]</button>
<button type="button" class="tb-btn" onclick="AdminApp.insertMD('## ','')" title="Heading">H2</button>
<button type="button" class="tb-btn" onclick="AdminApp.insertMD('### ','')" title="Heading">H3</button>
<button type="button" class="tb-btn" onclick="AdminApp.insertMD('[','](url)')" title="Link">🔗</button>
<button type="button" class="tb-btn" onclick="AdminApp.insertMD('---\n','')" title="Divider"></button>
<span class="tb-wordcount" id="edWordCount">0 words</span>
</div>
<textarea id="edContent" class="editor-textarea" rows="20" placeholder="Write your transmission in markdown..."></textarea>
</div>
<!-- Preview -->
<div class="editor-field full">
<label class="editor-label">PREVIEW</label>
<div class="editor-preview" id="edPreview"></div>
</div>
<div class="editor-actions">
<button type="submit" class="action-btn">TRANSMIT POST →</button>
<button type="button" class="action-btn secondary" onclick="AdminApp.clearEditor()">CLEAR</button>
</div>
</form>
</section>
<!-- Tracks Section -->
<section class="admin-section" id="section-tracks" style="display:none;">
<div class="section-heading-row">
<h2 class="section-heading">TRACK LIBRARY</h2>
<button class="action-btn" onclick="AdminApp.showAddTrack()">+ ADD TRACK</button>
</div>
<div class="track-add-form" id="trackAddForm" style="display:none;">
<input type="text" id="trackArtist" class="editor-input" placeholder="Artist">
<input type="text" id="trackTitle" class="editor-input" placeholder="Track">
<input type="text" id="trackAlbum" class="editor-input" placeholder="Album">
<button class="action-btn" onclick="AdminApp.addTrack()">ADD</button>
</div>
<div class="tracks-list" id="tracksList"></div>
</section>
<!-- Settings Section -->
<section class="admin-section" id="section-settings" style="display:none;">
<h2 class="section-heading">CONFIGURATION</h2>
<form class="settings-form" id="settingsForm">
<div class="settings-group">
<h3 class="settings-group-title">WIDGET TOGGLES</h3>
<label class="toggle-row">
<input type="checkbox" id="setWeather" checked>
<span class="toggle-label">Weather Widget</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="setNowPlaying" checked>
<span class="toggle-label">Now Playing Widget</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="setThreats" checked>
<span class="toggle-label">CVE Threat Feed</span>
</label>
<label class="toggle-row">
<input type="checkbox" id="setTerminal" checked>
<span class="toggle-label">Terminal Emulator</span>
</label>
</div>
<button type="submit" class="action-btn">SAVE SETTINGS</button>
</form>
</section>
</main>
</div>
<script src="js/admin.js"></script>
</body>
</html>

View file

@ -221,6 +221,35 @@ def now_playing():
t = random.choice(tracks)
return jsonify(t)
# ─── Tracks CRUD ─────────────────────────────────────
@app.route('/api/tracks')
def get_tracks():
return jsonify(load_json('tracks.json'))
@app.route('/api/tracks', methods=['POST'])
@require_auth
def add_track():
d = request.get_json(force=True)
tracks = load_json('tracks.json')
tracks.append({
'artist': d.get('artist', ''),
'track': d.get('track', ''),
'album': d.get('album', ''),
})
save_json('tracks.json', tracks)
return jsonify(tracks[-1]), 201
@app.route('/api/tracks/<int:index>', methods=['DELETE'])
@require_auth
def delete_track(index):
tracks = load_json('tracks.json')
if 0 <= index < len(tracks):
removed = tracks.pop(index)
save_json('tracks.json', tracks)
return jsonify({'ok': True, 'removed': removed})
abort(404)
# ─── Git Activity (from Gitea API) ───────────────────
@app.route('/api/git-activity')
def git_activity():

850
css/admin.css Normal file
View file

@ -0,0 +1,850 @@
/* ===================================================
JAESWIFT Admin Panel Styles
=================================================== */
:root {
--bg: #0a0e17;
--bg2: #0f1420;
--bg3: #151b2b;
--accent: #00ffc8;
--accent-dim: rgba(0, 255, 200, 0.15);
--text: #c8d6e5;
--text-dim: rgba(200, 214, 229, 0.5);
--danger: #ff4757;
--warn: #ffa502;
--success: #00ffc8;
--border: rgba(0, 255, 200, 0.08);
--sidebar-w: 220px;
--topbar-h: 50px;
--font-mono: 'JetBrains Mono', monospace;
--font-display: 'Orbitron', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.6;
min-height: 100vh;
}
/* ─── Login Screen ─── */
.admin-login {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: radial-gradient(ellipse at center, rgba(0,255,200,0.03) 0%, transparent 70%), var(--bg);
}
.login-box {
width: 380px;
max-width: 95vw;
border: 1px solid var(--border);
background: var(--bg2);
padding: 2.5rem;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-icon {
font-size: 2.5rem;
color: var(--accent);
text-shadow: 0 0 20px rgba(0,255,200,0.3);
margin-bottom: 0.5rem;
}
.login-title {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: #fff;
letter-spacing: 4px;
margin-bottom: 0.3rem;
}
.login-subtitle {
font-size: 0.65rem;
color: var(--text-dim);
letter-spacing: 2px;
}
.login-field {
margin-bottom: 1.2rem;
}
.login-label {
display: block;
font-size: 0.6rem;
color: var(--accent);
letter-spacing: 2px;
margin-bottom: 0.4rem;
}
.login-input {
width: 100%;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
color: #fff;
font-family: var(--font-mono);
font-size: 0.85rem;
padding: 0.7rem 1rem;
outline: none;
transition: border-color 0.3s;
}
.login-input:focus {
border-color: var(--accent);
box-shadow: 0 0 8px rgba(0,255,200,0.15);
}
.login-error {
color: var(--danger);
font-size: 0.7rem;
letter-spacing: 1px;
min-height: 1.2rem;
margin-bottom: 0.5rem;
}
.login-btn {
width: 100%;
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 2px;
padding: 0.8rem;
cursor: pointer;
transition: all 0.3s;
}
.login-btn:hover {
background: var(--accent-dim);
box-shadow: 0 0 15px rgba(0,255,200,0.2);
}
.login-footer {
text-align: center;
margin-top: 1.5rem;
}
.login-status {
font-size: 0.55rem;
color: rgba(200,214,229,0.25);
letter-spacing: 2px;
}
/* ─── App Layout ─── */
.admin-app {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: var(--topbar-h) 1fr;
min-height: 100vh;
}
/* ─── Top Bar ─── */
.admin-topbar {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
background: var(--bg2);
border-bottom: 1px solid var(--border);
z-index: 10;
}
.topbar-left {
display: flex;
align-items: center;
gap: 0.8rem;
}
.topbar-logo {
font-family: var(--font-display);
font-size: 0.85rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 2px;
}
.topbar-divider {
color: var(--text-dim);
font-size: 0.7rem;
}
.topbar-section {
font-size: 0.7rem;
color: var(--text-dim);
letter-spacing: 2px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 1rem;
}
.topbar-user {
font-size: 0.7rem;
color: var(--accent);
letter-spacing: 1px;
}
.topbar-btn {
background: transparent;
border: 1px solid rgba(255,71,87,0.3);
color: var(--danger);
font-family: var(--font-mono);
font-size: 0.65rem;
letter-spacing: 1px;
padding: 0.3rem 0.8rem;
cursor: pointer;
transition: all 0.3s;
}
.topbar-btn:hover {
background: rgba(255,71,87,0.1);
}
/* ─── Sidebar ─── */
.admin-sidebar {
background: var(--bg2);
border-right: 1px solid var(--border);
padding: 1rem 0;
display: flex;
flex-direction: column;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.7rem 1.2rem;
color: var(--text-dim);
text-decoration: none;
font-size: 0.7rem;
letter-spacing: 1.5px;
transition: all 0.2s;
border-left: 2px solid transparent;
}
.sidebar-link:hover {
color: var(--text);
background: rgba(0,255,200,0.03);
}
.sidebar-link.active {
color: var(--accent);
background: rgba(0,255,200,0.05);
border-left-color: var(--accent);
}
.sidebar-icon {
font-size: 0.9rem;
width: 1.2rem;
text-align: center;
}
.sidebar-footer {
margin-top: auto;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
/* ─── Main Content ─── */
.admin-main {
padding: 2rem;
overflow-y: auto;
max-height: calc(100vh - var(--topbar-h));
}
.admin-section {
max-width: 1200px;
}
.section-heading {
font-family: var(--font-display);
font-size: 1rem;
font-weight: 700;
color: #fff;
letter-spacing: 3px;
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.section-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.section-heading-row .section-heading {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.section-subheading {
font-family: var(--font-display);
font-size: 0.75rem;
color: var(--accent);
letter-spacing: 2px;
margin: 2rem 0 1rem;
}
/* ─── Action Buttons ─── */
.action-btn {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 2px;
padding: 0.5rem 1.5rem;
cursor: pointer;
transition: all 0.3s;
}
.action-btn:hover {
background: var(--accent-dim);
box-shadow: 0 0 10px rgba(0,255,200,0.15);
}
.action-btn.secondary {
border-color: var(--text-dim);
color: var(--text-dim);
}
.action-btn.secondary:hover {
background: rgba(200,214,229,0.05);
}
.action-sm {
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.6rem;
letter-spacing: 1px;
padding: 0.25rem 0.6rem;
cursor: pointer;
transition: all 0.2s;
}
.action-sm.edit:hover { color: var(--accent); border-color: var(--accent); }
.action-sm.delete:hover { color: var(--danger); border-color: var(--danger); }
/* ─── Dashboard Grid ─── */
.dash-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
.dash-card {
background: var(--bg2);
border: 1px solid var(--border);
padding: 1.2rem;
text-align: center;
}
.dash-card-label {
display: block;
font-size: 0.55rem;
color: var(--text-dim);
letter-spacing: 2px;
margin-bottom: 0.5rem;
}
.dash-card-value {
font-family: var(--font-display);
font-size: 1.6rem;
font-weight: 700;
color: #fff;
text-shadow: 0 0 10px rgba(0,255,200,0.2);
}
/* ─── Services Grid ─── */
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.8rem;
}
.service-card {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: var(--bg3);
border: 1px solid var(--border);
font-size: 0.75rem;
}
.service-card.online .service-dot { color: var(--accent); }
.service-card.offline .service-dot { color: var(--danger); }
.service-name { flex: 1; color: var(--text); }
.service-ms { color: var(--text-dim); font-size: 0.6rem; }
/* ─── Threats ─── */
.threat-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(0,255,200,0.04);
font-size: 0.7rem;
}
.threat-id {
color: var(--accent);
font-size: 0.65rem;
min-width: 140px;
}
.threat-summary {
flex: 1;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.threat-cvss {
font-family: var(--font-display);
font-size: 0.7rem;
font-weight: 700;
min-width: 35px;
text-align: center;
}
.cvss-crit { color: #ff0040; text-shadow: 0 0 8px rgba(255,0,64,0.4); }
.cvss-high { color: var(--danger); }
.cvss-med { color: var(--warn); }
.cvss-low { color: var(--accent); }
/* ─── Posts Table ─── */
.posts-table-wrap {
overflow-x: auto;
}
.posts-table {
width: 100%;
border-collapse: collapse;
}
.posts-table th {
font-size: 0.6rem;
color: var(--accent);
letter-spacing: 2px;
text-align: left;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.posts-table td {
padding: 0.6rem 0.8rem;
border-bottom: 1px solid rgba(0,255,200,0.03);
font-size: 0.75rem;
vertical-align: middle;
}
.posts-table tr:hover td {
background: rgba(0,255,200,0.02);
}
.post-link {
color: #fff;
text-decoration: none;
transition: color 0.2s;
}
.post-link:hover { color: var(--accent); }
.tag-pill {
display: inline-block;
font-size: 0.55rem;
padding: 0.1rem 0.4rem;
border: 1px solid var(--border);
color: var(--accent);
letter-spacing: 1px;
text-transform: uppercase;
margin-right: 0.3rem;
}
.threat-badge {
font-size: 0.6rem;
padding: 0.15rem 0.5rem;
letter-spacing: 1px;
font-weight: 600;
}
.threat-low { color: var(--accent); border: 1px solid rgba(0,255,200,0.2); }
.threat-medium { color: var(--warn); border: 1px solid rgba(255,165,2,0.2); }
.threat-high { color: var(--danger); border: 1px solid rgba(255,71,87,0.2); }
.threat-critical { color: #ff0040; border: 1px solid rgba(255,0,64,0.3); animation: critPulse 1.5s ease-in-out infinite; }
@keyframes critPulse { 0%,100%{opacity:1;} 50%{opacity:0.5;} }
.actions-cell {
display: flex;
gap: 0.4rem;
white-space: nowrap;
}
/* ─── Editor Form ─── */
.editor-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.editor-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.editor-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.editor-field.full { grid-column: 1 / -1; }
.editor-label {
font-size: 0.6rem;
color: var(--accent);
letter-spacing: 2px;
}
.editor-input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
color: #fff;
font-family: var(--font-mono);
font-size: 0.8rem;
padding: 0.5rem 0.8rem;
outline: none;
transition: border-color 0.3s;
}
.editor-input:focus {
border-color: var(--accent);
}
.editor-select {
cursor: pointer;
}
.editor-small {
max-width: 100px;
}
.editor-range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
background: rgba(0,255,200,0.1);
outline: none;
border-radius: 0;
}
.editor-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: var(--accent);
cursor: pointer;
border: none;
box-shadow: 0 0 8px rgba(0,255,200,0.4);
}
.range-val {
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 700;
color: var(--accent);
text-align: center;
}
/* ─── Operator Status (HUD) Section in Editor ─── */
.editor-hud-section {
background: var(--bg2);
border: 1px solid var(--border);
padding: 1.2rem;
margin: 0.5rem 0;
}
.editor-hud-title {
font-family: var(--font-display);
font-size: 0.75rem;
color: var(--accent);
letter-spacing: 2px;
margin-bottom: 1rem;
}
.editor-hud-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
align-items: end;
}
.hud-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
/* ─── Editor Toolbar ─── */
.editor-toolbar {
display: flex;
gap: 0.3rem;
padding: 0.5rem;
background: var(--bg3);
border: 1px solid var(--border);
border-bottom: none;
flex-wrap: wrap;
align-items: center;
}
.tb-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.7rem;
padding: 0.3rem 0.6rem;
cursor: pointer;
transition: all 0.2s;
min-width: 30px;
text-align: center;
}
.tb-btn:hover {
color: var(--accent);
border-color: var(--accent);
}
.tb-wordcount {
margin-left: auto;
font-size: 0.6rem;
color: var(--text-dim);
letter-spacing: 1px;
}
.editor-textarea {
width: 100%;
background: rgba(0,0,0,0.4);
border: 1px solid var(--border);
color: var(--text);
font-family: 'Share Tech Mono', var(--font-mono);
font-size: 0.85rem;
line-height: 1.7;
padding: 1.2rem;
outline: none;
resize: vertical;
min-height: 300px;
}
.editor-textarea:focus {
border-color: var(--accent);
}
.editor-preview {
background: var(--bg3);
border: 1px solid var(--border);
padding: 1.5rem;
min-height: 200px;
max-height: 500px;
overflow-y: auto;
}
.editor-preview .post-rendered h1,
.editor-preview .post-rendered h2,
.editor-preview .post-rendered h3 {
font-family: var(--font-display);
color: #fff;
margin: 1rem 0 0.5rem;
}
.editor-preview .post-rendered h2 {
font-size: 1rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.3rem;
}
.editor-preview .post-rendered strong { color: #fff; }
.editor-preview .post-rendered em { color: var(--accent); }
.editor-preview .post-rendered pre {
background: rgba(0,0,0,0.4);
border-left: 3px solid var(--accent);
padding: 1rem;
margin: 1rem 0;
overflow-x: auto;
}
.editor-preview .post-rendered code {
font-family: 'Share Tech Mono', monospace;
color: var(--accent);
}
.editor-preview .post-rendered .inline-code {
background: rgba(0,255,200,0.06);
border: 1px solid var(--border);
padding: 0.1rem 0.3rem;
}
.editor-actions {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
/* ─── Tracks ─── */
.track-add-form {
display: flex;
gap: 0.8rem;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.track-add-form .editor-input {
flex: 1;
min-width: 150px;
}
.track-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0.8rem;
border-bottom: 1px solid rgba(0,255,200,0.04);
font-size: 0.75rem;
transition: background 0.2s;
}
.track-row:hover {
background: rgba(0,255,200,0.02);
}
.track-num {
color: var(--text-dim);
font-size: 0.65rem;
min-width: 25px;
}
.track-title {
color: #fff;
font-weight: 600;
flex: 2;
}
.track-artist {
color: var(--accent);
flex: 2;
}
.track-album {
color: var(--text-dim);
flex: 2;
font-size: 0.7rem;
}
/* ─── Settings ─── */
.settings-form {
max-width: 500px;
}
.settings-group {
background: var(--bg2);
border: 1px solid var(--border);
padding: 1.2rem;
margin-bottom: 1.5rem;
}
.settings-group-title {
font-family: var(--font-display);
font-size: 0.7rem;
color: var(--accent);
letter-spacing: 2px;
margin-bottom: 1rem;
}
.toggle-row {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.5rem 0;
cursor: pointer;
}
.toggle-row input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
width: 36px;
height: 18px;
background: rgba(200,214,229,0.1);
border: 1px solid var(--border);
position: relative;
cursor: pointer;
transition: all 0.3s;
}
.toggle-row input[type="checkbox"]::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: var(--text-dim);
transition: all 0.3s;
}
.toggle-row input[type="checkbox"]:checked {
background: rgba(0,255,200,0.15);
border-color: var(--accent);
}
.toggle-row input[type="checkbox"]:checked::after {
left: 20px;
background: var(--accent);
box-shadow: 0 0 6px rgba(0,255,200,0.4);
}
.toggle-label {
font-size: 0.75rem;
color: var(--text);
}
/* ─── Notification ─── */
.admin-notification {
position: fixed;
top: -60px;
left: 50%;
transform: translateX(-50%);
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 2px;
padding: 0.7rem 2rem;
border: 1px solid var(--accent);
background: var(--bg2);
color: var(--accent);
z-index: 1000;
transition: top 0.4s ease;
}
.admin-notification.show {
top: 15px;
}
.admin-notification.error {
border-color: var(--danger);
color: var(--danger);
}
.no-data {
font-size: 0.7rem;
color: var(--text-dim);
letter-spacing: 1px;
}
/* ─── Responsive ─── */
@media (max-width: 768px) {
.admin-app {
grid-template-columns: 1fr;
}
.admin-sidebar {
display: none;
}
.admin-main {
padding: 1rem;
}
.dash-grid {
grid-template-columns: repeat(2, 1fr);
}
.editor-hud-grid {
grid-template-columns: repeat(2, 1fr);
}
}

View file

@ -277,6 +277,22 @@
min-width: 80px;
}
.stat-mood-badge {
font-family: 'Orbitron', sans-serif;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 2px;
padding: 0.2rem 0.6rem;
border: 1px solid;
text-shadow: 0 0 8px currentColor;
animation: moodGlow 2s ease-in-out infinite alternate;
}
@keyframes moodGlow {
0% { opacity: 0.8; }
100% { opacity: 1; text-shadow: 0 0 12px currentColor; }
}
.stat-pips {
display: flex;
gap: 4px;

View file

@ -1632,3 +1632,105 @@ a:hover { color: #fff; text-shadow: 0 0 10px var(--accent-glow); }
background: rgba(0, 255, 200, 0.08) !important;
box-shadow: 0 0 15px rgba(0, 255, 200, 0.15);
}
/* ─── Weather Display ─── */
.weather-display {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.weather-main {
display: flex;
align-items: baseline;
gap: 1rem;
}
.weather-temp {
font-family: 'Orbitron', sans-serif;
font-size: 2rem;
font-weight: 700;
color: #fff;
text-shadow: 0 0 15px rgba(0, 255, 200, 0.3);
}
.weather-cond {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
color: rgba(0, 255, 200, 0.6);
letter-spacing: 1px;
}
.weather-details {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.weather-detail {
font-family: 'JetBrains Mono', monospace;
font-size: 0.6rem;
color: rgba(200, 214, 229, 0.4);
letter-spacing: 1px;
padding: 0.2rem 0.5rem;
border: 1px solid rgba(0, 255, 200, 0.06);
}
/* ─── Now Playing Display ─── */
.np-display {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.np-track {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: #fff;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.np-artist {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
color: var(--accent, #00ffc8);
letter-spacing: 1px;
}
.np-album {
font-family: 'JetBrains Mono', monospace;
font-size: 0.6rem;
color: rgba(200, 214, 229, 0.35);
letter-spacing: 0.5px;
margin-bottom: 0.3rem;
}
.np-visualiser {
display: flex;
gap: 3px;
align-items: flex-end;
height: 18px;
margin-top: 0.3rem;
}
.np-bar {
width: 4px;
background: var(--accent, #00ffc8);
border-radius: 1px;
animation: npBounce 0.8s ease-in-out infinite alternate;
box-shadow: 0 0 4px rgba(0, 255, 200, 0.3);
}
.np-bar:nth-child(1) { height: 40%; animation-delay: 0s; }
.np-bar:nth-child(2) { height: 70%; animation-delay: 0.1s; }
.np-bar:nth-child(3) { height: 50%; animation-delay: 0.2s; }
.np-bar:nth-child(4) { height: 90%; animation-delay: 0.05s; }
.np-bar:nth-child(5) { height: 60%; animation-delay: 0.15s; }
.np-bar:nth-child(6) { height: 80%; animation-delay: 0.25s; }
.np-bar:nth-child(7) { height: 45%; animation-delay: 0.1s; }
.np-bar:nth-child(8) { height: 65%; animation-delay: 0.2s; }
@keyframes npBounce {
0% { transform: scaleY(0.3); }
100% { transform: scaleY(1); }
}
#npStatus {
color: var(--accent, #00ffc8);
animation: statusPulse 2s ease-in-out infinite;
}
@keyframes statusPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}

View file

@ -214,6 +214,44 @@
</div>
</div>
<!-- Weather + Now Playing -->
<div class="panel hud-data-panel hud-row-panels">
<div class="hud-mini-panel">
<div class="panel-header">
<span class="panel-title">WEATHER</span>
<span class="panel-status-dot status-green">● LIVE</span>
</div>
<div class="panel-content weather-display">
<div class="weather-main">
<span class="weather-temp" id="weatherTemp">--°C</span>
<span class="weather-cond" id="weatherCond">LOADING...</span>
</div>
<div class="weather-details">
<span class="weather-detail" id="weatherFeels">FEELS --°C</span>
<span class="weather-detail" id="weatherWind">-- KPH</span>
<span class="weather-detail" id="weatherHumidity">--% RH</span>
</div>
</div>
</div>
<div class="hud-mini-panel">
<div class="panel-header">
<span class="panel-title">NOW PLAYING</span>
<span class="panel-status-dot" id="npStatus">● STREAMING</span>
</div>
<div class="panel-content np-display">
<div class="np-track" id="npTrack">---</div>
<div class="np-artist" id="npArtist">---</div>
<div class="np-album" id="npAlbum">---</div>
<div class="np-visualiser">
<span class="np-bar"></span><span class="np-bar"></span><span class="np-bar"></span>
<span class="np-bar"></span><span class="np-bar"></span><span class="np-bar"></span>
<span class="np-bar"></span><span class="np-bar"></span>
</div>
</div>
</div>
</div>
<!-- Network Graph -->
<div class="panel hud-data-panel">
<div class="panel-header">

461
js/admin.js Normal file
View file

@ -0,0 +1,461 @@
/* ===================================================
JAESWIFT Admin Panel JS
=================================================== */
const AdminApp = (() => {
const API = window.location.hostname === 'localhost' ? 'http://localhost:5000' : '/api';
let token = localStorage.getItem('jaeswift_token') || '';
// ─── Auth ───
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
}
async function login(e) {
e.preventDefault();
const user = document.getElementById('loginUser').value;
const pass = document.getElementById('loginPass').value;
const errEl = document.getElementById('loginError');
errEl.textContent = '';
try {
const r = await fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass })
});
const d = await r.json();
if (d.token) {
token = d.token;
localStorage.setItem('jaeswift_token', token);
showApp();
} else {
errEl.textContent = 'ACCESS DENIED: ' + (d.error || 'Invalid credentials');
}
} catch {
errEl.textContent = 'CONNECTION FAILED';
}
}
function logout() {
token = '';
localStorage.removeItem('jaeswift_token');
document.getElementById('adminApp').style.display = 'none';
document.getElementById('loginScreen').style.display = '';
}
async function checkAuth() {
if (!token) return false;
try {
const r = await fetch(API + '/auth/check', { headers: headers() });
const d = await r.json();
return d.valid === true;
} catch { return false; }
}
async function showApp() {
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('adminApp').style.display = '';
loadDashboard();
}
// ─── Section Switching ───
function showSection(name) {
document.querySelectorAll('.admin-section').forEach(s => s.style.display = 'none');
const target = document.getElementById('section-' + name);
if (target) target.style.display = '';
document.querySelectorAll('.sidebar-link').forEach(l => l.classList.remove('active'));
const link = document.querySelector('.sidebar-link[data-section="' + name + '"]');
if (link) link.classList.add('active');
const titles = { dashboard: 'DASHBOARD', posts: 'POSTS', editor: 'EDITOR', tracks: 'TRACKS', settings: 'SETTINGS' };
document.getElementById('topbarSection').textContent = titles[name] || name.toUpperCase();
if (name === 'dashboard') loadDashboard();
if (name === 'posts') loadPosts();
if (name === 'tracks') loadTracks();
if (name === 'settings') loadSettings();
if (name === 'editor') {
if (!document.getElementById('editSlugOriginal').value) {
document.getElementById('editorHeading').textContent = 'NEW TRANSMISSION';
}
}
}
// ─── Dashboard ───
async function loadDashboard() {
try {
const [posts, tracks, stats, services, threats] = await Promise.all([
fetch(API + '/posts').then(r => r.json()),
fetch(API + '/nowplaying').then(r => r.json()).catch(() => null),
fetch(API + '/stats').then(r => r.json()),
fetch(API + '/services').then(r => r.json()).catch(() => []),
fetch(API + '/threats').then(r => r.json()).catch(() => []),
]);
// Fetch tracks count
let trackCount = '--';
try {
const tr = await fetch(API + '/tracks');
if (tr.ok) { trackCount = (await tr.json()).length; }
} catch {
// tracks endpoint might not exist, try loading from data
trackCount = '~35';
}
document.getElementById('dashPosts').textContent = posts.length;
document.getElementById('dashWords').textContent = posts.reduce((sum, p) => sum + (p.word_count || 0), 0).toLocaleString();
document.getElementById('dashTracks').textContent = trackCount;
document.getElementById('dashCPU').textContent = Math.round(stats.cpu_percent) + '%';
document.getElementById('dashMem').textContent = Math.round(stats.memory_percent) + '%';
document.getElementById('dashDisk').textContent = stats.disk_percent + '%';
// Services
const svcEl = document.getElementById('dashServices');
svcEl.innerHTML = services.map(s => `
<div class="service-card ${s.status}">
<span class="service-dot">${s.status === 'online' ? '●' : '○'}</span>
<span class="service-name">${s.name}</span>
<span class="service-ms">${s.response_time_ms}ms</span>
</div>
`).join('');
// Threats
const threatEl = document.getElementById('dashThreats');
threatEl.innerHTML = threats.length ? threats.map(t => `
<div class="threat-row">
<span class="threat-id">${t.id}</span>
<span class="threat-summary">${t.summary}</span>
<span class="threat-cvss cvss-${t.cvss >= 9 ? 'crit' : t.cvss >= 7 ? 'high' : t.cvss >= 4 ? 'med' : 'low'}">${t.cvss || '?'}</span>
</div>
`).join('') : '<span class="no-data">No threats detected</span>';
} catch (e) {
console.error('Dashboard load error:', e);
}
}
// ─── Posts ───
async function loadPosts() {
const posts = await fetch(API + '/posts').then(r => r.json());
posts.sort((a, b) => new Date(b.date) - new Date(a.date));
const tbody = document.getElementById('postsTableBody');
tbody.innerHTML = posts.map(p => `
<tr>
<td>${p.date || '--'}</td>
<td><a href="post.html?slug=${p.slug}" target="_blank" class="post-link">${p.title}</a></td>
<td>${(p.tags || []).map(t => '<span class="tag-pill">' + t + '</span>').join(' ')}</td>
<td>${p.word_count || 0}</td>
<td><span class="threat-badge threat-${p.threat_level || 'low'}">${(p.threat_level || 'low').toUpperCase()}</span></td>
<td class="actions-cell">
<button class="action-sm edit" onclick="AdminApp.editPost('${p.slug}')">EDIT</button>
<button class="action-sm delete" onclick="AdminApp.deletePost('${p.slug}')">DEL</button>
</td>
</tr>
`).join('');
}
async function editPost(slug) {
const post = await fetch(API + '/posts/' + slug).then(r => r.json());
document.getElementById('editSlugOriginal').value = slug;
document.getElementById('edTitle').value = post.title || '';
document.getElementById('edSlug').value = post.slug || '';
document.getElementById('edDate').value = post.date || '';
document.getElementById('edTime').value = post.time || '00:00';
document.getElementById('edTags').value = (post.tags || []).join(', ');
document.getElementById('edExcerpt').value = post.excerpt || '';
document.getElementById('edContent').value = post.content || '';
document.getElementById('edMood').value = post.mood || 'focused';
document.getElementById('edEnergy').value = post.energy || 3;
document.getElementById('edEnergyVal').textContent = post.energy || 3;
document.getElementById('edMotivation').value = post.motivation || 3;
document.getElementById('edMotivationVal').textContent = post.motivation || 3;
document.getElementById('edFocus').value = post.focus || 3;
document.getElementById('edFocusVal').textContent = post.focus || 3;
document.getElementById('edDifficulty').value = post.difficulty || 3;
document.getElementById('edDifficultyVal').textContent = post.difficulty || 3;
document.getElementById('edCoffee').value = post.coffee || 2;
document.getElementById('edBPM').value = post.bpm || post.heart_rate || 72;
document.getElementById('edThreat').value = post.threat_level || 'low';
document.getElementById('editorHeading').textContent = 'EDIT TRANSMISSION';
updateWordCount();
updatePreview();
showSection('editor');
}
async function savePost(e) {
e.preventDefault();
const slugOrig = document.getElementById('editSlugOriginal').value;
const title = document.getElementById('edTitle').value.trim();
const slug = document.getElementById('edSlug').value.trim() || title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const data = {
title,
slug,
date: document.getElementById('edDate').value || new Date().toISOString().split('T')[0],
time: document.getElementById('edTime').value || '00:00',
time_written: document.getElementById('edTime').value || '00:00',
tags: document.getElementById('edTags').value.split(',').map(t => t.trim()).filter(Boolean),
excerpt: document.getElementById('edExcerpt').value.trim(),
content: document.getElementById('edContent').value,
mood: document.getElementById('edMood').value,
energy: parseInt(document.getElementById('edEnergy').value),
motivation: parseInt(document.getElementById('edMotivation').value),
focus: parseInt(document.getElementById('edFocus').value),
difficulty: parseInt(document.getElementById('edDifficulty').value),
coffee: parseInt(document.getElementById('edCoffee').value),
bpm: parseInt(document.getElementById('edBPM').value),
heart_rate: parseInt(document.getElementById('edBPM').value),
threat_level: document.getElementById('edThreat').value,
};
const method = slugOrig ? 'PUT' : 'POST';
const url = slugOrig ? API + '/posts/' + slugOrig : API + '/posts';
try {
const r = await fetch(url, { method, headers: headers(), body: JSON.stringify(data) });
if (r.ok) {
clearEditor();
showSection('posts');
showNotification('TRANSMISSION ' + (slugOrig ? 'UPDATED' : 'SENT'));
} else {
const err = await r.json();
showNotification('ERROR: ' + (err.message || r.status), 'error');
}
} catch (e) {
showNotification('TRANSMISSION FAILED: ' + e.message, 'error');
}
}
async function deletePost(slug) {
if (!confirm('Delete post "' + slug + '"? This cannot be undone.')) return;
const r = await fetch(API + '/posts/' + slug, { method: 'DELETE', headers: headers() });
if (r.ok) {
loadPosts();
showNotification('POST DELETED');
}
}
function clearEditor() {
document.getElementById('editSlugOriginal').value = '';
document.getElementById('editorForm').reset();
document.getElementById('edEnergyVal').textContent = '3';
document.getElementById('edMotivationVal').textContent = '3';
document.getElementById('edFocusVal').textContent = '3';
document.getElementById('edDifficultyVal').textContent = '3';
document.getElementById('edWordCount').textContent = '0 words';
document.getElementById('edPreview').innerHTML = '';
document.getElementById('editorHeading').textContent = 'NEW TRANSMISSION';
}
// ─── Markdown Helpers ───
function insertMD(before, after) {
const ta = document.getElementById('edContent');
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = ta.value.substring(start, end);
ta.value = ta.value.substring(0, start) + before + sel + after + ta.value.substring(end);
ta.focus();
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + sel.length;
updateWordCount();
updatePreview();
}
function updateWordCount() {
const text = document.getElementById('edContent').value.trim();
const count = text ? text.split(/\s+/).length : 0;
document.getElementById('edWordCount').textContent = count + ' words';
}
function updatePreview() {
const md = document.getElementById('edContent').value;
document.getElementById('edPreview').innerHTML = renderMarkdown(md);
}
function renderMarkdown(md) {
let html = md
.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^#### (.+)$/gm, '<h4>$1</h4>')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
.replace(/^---$/gm, '<hr>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
return '<div class="post-rendered"><p>' + html + '</p></div>';
}
// ─── Tracks ───
async function loadTracks() {
try {
// Try fetching from tracks API endpoint
let tracks;
try {
const r = await fetch(API + '/tracks');
if (r.ok) {
tracks = await r.json();
} else throw new Error();
} catch {
// Fallback: load from data file directly
tracks = [];
}
const list = document.getElementById('tracksList');
if (!tracks.length) {
list.innerHTML = '<span class="no-data">No tracks loaded — API endpoint /api/tracks may need adding.</span>';
return;
}
list.innerHTML = tracks.map((t, i) => `
<div class="track-row">
<span class="track-num">${String(i + 1).padStart(2, '0')}</span>
<span class="track-title">${t.track}</span>
<span class="track-artist">${t.artist}</span>
<span class="track-album">${t.album || ''}</span>
<button class="action-sm delete" onclick="AdminApp.deleteTrack(${i})"></button>
</div>
`).join('');
} catch (e) {
console.error('Track load error:', e);
}
}
function showAddTrack() {
const f = document.getElementById('trackAddForm');
f.style.display = f.style.display === 'none' ? 'flex' : 'none';
}
async function addTrack() {
const artist = document.getElementById('trackArtist').value.trim();
const track = document.getElementById('trackTitle').value.trim();
const album = document.getElementById('trackAlbum').value.trim();
if (!artist || !track) return;
try {
const r = await fetch(API + '/tracks', {
method: 'POST',
headers: headers(),
body: JSON.stringify({ artist, track, album })
});
if (r.ok) {
document.getElementById('trackArtist').value = '';
document.getElementById('trackTitle').value = '';
document.getElementById('trackAlbum').value = '';
loadTracks();
showNotification('TRACK ADDED');
}
} catch (e) {
showNotification('Failed to add track', 'error');
}
}
async function deleteTrack(index) {
try {
const r = await fetch(API + '/tracks/' + index, { method: 'DELETE', headers: headers() });
if (r.ok) loadTracks();
} catch {}
}
// ─── Settings ───
async function loadSettings() {
try {
const settings = await fetch(API + '/settings').then(r => r.json());
if (settings.weather !== undefined) document.getElementById('setWeather').checked = settings.weather;
if (settings.nowplaying !== undefined) document.getElementById('setNowPlaying').checked = settings.nowplaying;
if (settings.threats !== undefined) document.getElementById('setThreats').checked = settings.threats;
if (settings.terminal !== undefined) document.getElementById('setTerminal').checked = settings.terminal;
} catch {}
}
async function saveSettings(e) {
e.preventDefault();
const data = {
weather: document.getElementById('setWeather').checked,
nowplaying: document.getElementById('setNowPlaying').checked,
threats: document.getElementById('setThreats').checked,
terminal: document.getElementById('setTerminal').checked,
};
await fetch(API + '/settings', { method: 'PUT', headers: headers(), body: JSON.stringify(data) });
showNotification('SETTINGS SAVED');
}
// ─── Notifications ───
function showNotification(msg, type = 'success') {
let notif = document.querySelector('.admin-notification');
if (!notif) {
notif = document.createElement('div');
notif.className = 'admin-notification';
document.body.appendChild(notif);
}
notif.textContent = msg;
notif.className = 'admin-notification ' + type + ' show';
setTimeout(() => notif.classList.remove('show'), 3000);
}
// ─── Init ───
async function init() {
// Check if already authed
if (await checkAuth()) {
showApp();
}
// Login form
document.getElementById('loginForm').addEventListener('submit', login);
// Editor form
document.getElementById('editorForm').addEventListener('submit', savePost);
// Settings form
document.getElementById('settingsForm').addEventListener('submit', saveSettings);
// Auto slug from title
document.getElementById('edTitle').addEventListener('input', (e) => {
const slugField = document.getElementById('edSlug');
if (!document.getElementById('editSlugOriginal').value) {
slugField.value = e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
});
// Content updates
document.getElementById('edContent').addEventListener('input', () => {
updateWordCount();
updatePreview();
});
// Energy range display
document.getElementById('edEnergy').addEventListener('input', (e) => {
document.getElementById('edEnergyVal').textContent = e.target.value;
});
document.getElementById('edMotivation').addEventListener('input', (e) => {
document.getElementById('edMotivationVal').textContent = e.target.value;
});
document.getElementById('edFocus').addEventListener('input', (e) => {
document.getElementById('edFocusVal').textContent = e.target.value;
});
document.getElementById('edDifficulty').addEventListener('input', (e) => {
document.getElementById('edDifficultyVal').textContent = e.target.value;
});
// Set today's date default
document.getElementById('edDate').value = new Date().toISOString().split('T')[0];
}
document.addEventListener('DOMContentLoaded', init);
// Public API
return {
showSection,
logout,
editPost,
deletePost,
clearEditor,
insertMD,
showAddTrack,
addTrack,
deleteTrack,
};
})();

View file

@ -866,6 +866,8 @@
if (feelsEl) feelsEl.textContent = 'FEELS ' + d.feels_like + '°C';
const windEl = document.getElementById('weatherWind');
if (windEl) windEl.textContent = d.wind_kph + ' KPH ' + d.wind_dir;
const humEl = document.getElementById('weatherHumidity');
if (humEl) humEl.textContent = d.humidity + '% RH';
})
.catch(() => {});
}

View file

@ -102,18 +102,35 @@
}, { passive: true });
}
// ─── Mood Icons Map ───
const moodIcons = {
'focused': '◎', 'creative': '✦', 'productive': '⚡',
'tired': '◡', 'wired': '⚡', 'chaotic': '✸',
'locked-in': '◉', 'zen': '☯'
};
const moodColors = {
'focused': '#00ffc8', 'creative': '#a855f7', 'productive': '#22d3ee',
'tired': '#6b7280', 'wired': '#f59e0b', 'chaotic': '#ff4757',
'locked-in': '#00ff88', 'zen': '#818cf8'
};
// ─── Render Operator Stats ───
function renderStats(post) {
const el = document.getElementById('postStats');
if (!el) return;
const coffee = '☕'.repeat(post.coffee || 0) +
'<span style="opacity:0.2">' + '☕'.repeat(5 - (post.coffee || 0)) + '</span>';
const moodStr = (post.mood || 'focused').toLowerCase();
const moodIcon = moodIcons[moodStr] || '◈';
const moodColor = moodColors[moodStr] || '#00ffc8';
const hr = post.heart_rate || post.bpm || 72;
const coffee = '☕'.repeat(Math.min(post.coffee || 0, 10)) +
'<span style="opacity:0.2">' + '☕'.repeat(Math.max(0, 5 - (post.coffee || 0))) + '</span>';
el.innerHTML = `
<div class="stat-row">
<div class="stat-row mood-row">
<span class="stat-label">MOOD</span>
${buildPips(post.mood)}
<span class="stat-mood-badge" style="color:${moodColor};border-color:${moodColor}">${moodIcon} ${moodStr.toUpperCase()}</span>
</div>
<div class="stat-row">
<span class="stat-label">ENERGY</span>
@ -134,7 +151,7 @@
<div class="stat-divider"></div>
<div class="stat-big-row">
<div class="stat-bpm-display">
<span class="stat-bpm-value" id="bpmValue">${post.heart_rate || '---'}</span>
<span class="stat-bpm-value" id="bpmValue">${hr}</span>
<span class="stat-bpm-unit">BPM</span>
<div class="heartbeat-line" id="heartbeatLine"></div>
</div>
@ -146,7 +163,7 @@
`;
// Animate heartbeat
animateHeartbeat(post.heart_rate || 72);
animateHeartbeat(hr);
}
// ─── Heartbeat Animation ───