diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..8339b1b --- /dev/null +++ b/api/app.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""JAESWIFT HUD Backend API""" +import json, os, time, subprocess, random, datetime, hashlib +from functools import wraps +from pathlib import Path + +from flask import Flask, request, jsonify, abort +from flask_cors import CORS +import jwt +import requests as req + +app = Flask(__name__) +CORS(app) + +DATA_DIR = Path(__file__).parent / 'data' +JWT_SECRET = 'jaeswift-hud-s3cr3t-2026!x' +ADMIN_USER = 'jae' +ADMIN_PASS = 'HUDAdmin2026!' + +# ─── Helpers ───────────────────────────────────────── +def load_json(name): + p = DATA_DIR / name + if p.exists(): + with open(p) as f: + return json.load(f) + return [] if name.endswith('posts.json') else {} + +def save_json(name, data): + p = DATA_DIR / name + p.parent.mkdir(parents=True, exist_ok=True) + with open(p, 'w') as f: + json.dump(data, f, indent=2) + +def require_auth(fn): + @wraps(fn) + def wrapper(*a, **kw): + auth = request.headers.get('Authorization', '') + if not auth.startswith('Bearer '): + abort(401, 'Missing token') + try: + jwt.decode(auth[7:], JWT_SECRET, algorithms=['HS256']) + except Exception: + abort(401, 'Invalid token') + return fn(*a, **kw) + return wrapper + +def shell(cmd): + try: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5) + return r.stdout.strip() + except Exception: + return '' + +# ─── Auth ──────────────────────────────────────────── +@app.route('/api/auth/login', methods=['POST']) +def login(): + d = request.get_json(force=True, silent=True) or {} + if d.get('username') == ADMIN_USER and d.get('password') == ADMIN_PASS: + token = jwt.encode( + {'user': ADMIN_USER, 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24)}, + JWT_SECRET, algorithm='HS256' + ) + return jsonify({'token': token}) + return jsonify({'error': 'Invalid credentials'}), 401 + +@app.route('/api/auth/check') +def auth_check(): + auth = request.headers.get('Authorization', '') + if not auth.startswith('Bearer '): + return jsonify({'valid': False}), 401 + try: + jwt.decode(auth[7:], JWT_SECRET, algorithms=['HS256']) + return jsonify({'valid': True, 'user': ADMIN_USER}) + except Exception: + return jsonify({'valid': False}), 401 + +# ─── Blog Posts ────────────────────────────────────── +@app.route('/api/posts') +def get_posts(): + posts = load_json('posts.json') + return jsonify(posts) + +@app.route('/api/posts/') +def get_post(slug): + posts = load_json('posts.json') + for p in posts: + if p.get('slug') == slug: + return jsonify(p) + abort(404) + +@app.route('/api/posts', methods=['POST']) +@require_auth +def create_post(): + d = request.get_json(force=True) + posts = load_json('posts.json') + d['id'] = max((p.get('id', 0) for p in posts), default=0) + 1 + d['date'] = d.get('date', datetime.date.today().isoformat()) + d['word_count'] = len(d.get('content', '').split()) + posts.append(d) + save_json('posts.json', posts) + return jsonify(d), 201 + +@app.route('/api/posts/', methods=['PUT']) +@require_auth +def update_post(slug): + d = request.get_json(force=True) + posts = load_json('posts.json') + for i, p in enumerate(posts): + if p.get('slug') == slug: + d['id'] = p['id'] + d['word_count'] = len(d.get('content', '').split()) + posts[i] = {**p, **d} + save_json('posts.json', posts) + return jsonify(posts[i]) + abort(404) + +@app.route('/api/posts/', methods=['DELETE']) +@require_auth +def delete_post(slug): + posts = load_json('posts.json') + posts = [p for p in posts if p.get('slug') != slug] + save_json('posts.json', posts) + return jsonify({'ok': True}) + +# ─── Server Stats ──────────────────────────────────── +@app.route('/api/stats') +def server_stats(): + # CPU + load = shell("cat /proc/loadavg | awk '{print $1}'") + ncpu = shell("nproc") + try: + cpu_pct = round(float(load) / max(int(ncpu), 1) * 100, 1) + except Exception: + cpu_pct = 0 + + # Memory + mem = shell("free | awk '/Mem:/{printf \"%.1f\", $3/$2*100}'") + + # Disk + disk = shell("df / | awk 'NR==2{print $5}' | tr -d '%'") + + # Network (bytes since boot) + net = shell("cat /proc/net/dev | awk '/eth0|ens/{print $2,$10}'") + parts = net.split() + rx = int(parts[0]) if len(parts) >= 2 else 0 + tx = int(parts[1]) if len(parts) >= 2 else 0 + + # Docker + running = shell("docker ps -q 2>/dev/null | wc -l") + total = shell("docker ps -aq 2>/dev/null | wc -l") + + # Uptime + up = shell("cat /proc/uptime | awk '{print $1}'") + + # Connections + conns = shell("ss -s | awk '/TCP:/{print $2}'") + + return jsonify({ + 'cpu_percent': cpu_pct, + 'memory_percent': float(mem) if mem else 0, + 'disk_percent': int(disk) if disk else 0, + 'network_rx_bytes': rx, + 'network_tx_bytes': tx, + 'container_running': int(running) if running else 0, + 'container_total': int(total) if total else 0, + 'uptime_seconds': float(up) if up else 0, + 'active_connections': int(conns) if conns else 0, + 'load_avg': float(load) if load else 0, + 'timestamp': time.time() + }) + +# ─── Services Status ───────────────────────────────── +@app.route('/api/services') +def services(): + svcs = [ + {'name': 'Gitea', 'url': 'https://git.jaeswift.xyz'}, + {'name': 'Plex', 'url': 'https://plex.jaeswift.xyz'}, + {'name': 'Search', 'url': 'https://jaeswift.xyz/search'}, + {'name': 'Yoink', 'url': 'https://jaeswift.xyz/yoink/'}, + {'name': 'Archive', 'url': 'https://archive.jaeswift.xyz'}, + {'name': 'Agent Zero', 'url': 'https://agentzero.jaeswift.xyz'}, + {'name': 'Files', 'url': 'https://files.jaeswift.xyz'}, + ] + results = [] + for s in svcs: + try: + t0 = time.time() + r = req.get(s['url'], timeout=5, 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}) + except Exception: + results.append({**s, 'status': 'offline', 'response_time_ms': 0}) + return jsonify(results) + +# ─── Weather ───────────────────────────────────────── +@app.route('/api/weather') +def weather(): + try: + r = req.get('https://wttr.in/Manchester?format=j1', timeout=5, + headers={'User-Agent': 'jaeswift-hud'}) + d = r.json() + cur = d['current_condition'][0] + return jsonify({ + 'temp_c': int(cur['temp_C']), + 'feels_like': int(cur['FeelsLikeC']), + 'condition': cur['weatherDesc'][0]['value'], + 'humidity': int(cur['humidity']), + 'wind_kph': int(cur['windspeedKmph']), + 'wind_dir': cur['winddir16Point'], + 'icon': cur.get('weatherCode', ''), + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# ─── Now Playing (random track) ────────────────────── +@app.route('/api/nowplaying') +def now_playing(): + tracks = load_json('tracks.json') + if not tracks: + return jsonify({'artist': 'Unknown', 'track': 'Silence', 'album': ''}) + t = random.choice(tracks) + return jsonify(t) + +# ─── Git Activity (from Gitea API) ─────────────────── +@app.route('/api/git-activity') +def git_activity(): + try: + r = req.get('https://git.jaeswift.xyz/api/v1/users/jae/heatmap', + timeout=5, 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) + repos = [] + if r2.status_code == 200: + data = r2.json().get('data', r2.json()) if isinstance(r2.json(), dict) else r2.json() + for repo in (data if isinstance(data, list) else [])[:5]: + repos.append({ + 'name': repo.get('name', ''), + 'updated': repo.get('updated_at', ''), + 'stars': repo.get('stars_count', 0), + 'language': repo.get('language', ''), + }) + + return jsonify({'heatmap': heatmap, 'repos': repos}) + except Exception as e: + return jsonify({'heatmap': [], 'repos': [], 'error': str(e)}) + +# ─── Threat Feed (CVE feed) ────────────────────────── +@app.route('/api/threats') +def threats(): + try: + r = req.get('https://cve.circl.lu/api/last/8', timeout=8) + cves = [] + if r.status_code == 200: + for item in r.json()[:8]: + cves.append({ + 'id': item.get('id', ''), + 'summary': (item.get('summary', '') or '')[:120], + 'published': item.get('Published', ''), + 'cvss': item.get('cvss', 0), + }) + return jsonify(cves) + except Exception: + return jsonify([]) + +# ─── Settings ──────────────────────────────────────── +@app.route('/api/settings') +def get_settings(): + return jsonify(load_json('settings.json')) + +@app.route('/api/settings', methods=['PUT']) +@require_auth +def update_settings(): + d = request.get_json(force=True) + save_json('settings.json', d) + return jsonify(d) + +# ─── Run ───────────────────────────────────────────── +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/api/data/posts.json b/api/data/posts.json new file mode 100644 index 0000000..e79f232 --- /dev/null +++ b/api/data/posts.json @@ -0,0 +1,78 @@ +[ + { + "id": 1, + "title": "Building a Self-Hosted Empire", + "slug": "self-hosted-empire", + "date": "2026-03-28", + "excerpt": "How I ditched big tech and built my own infrastructure from the ground up. Gitea, Plex, search engines — all on one box.", + "content": "## The Breaking Point\n\nThere comes a moment in every developer's life when you look at your Google Drive, your Gmail, your hosted repos on GitHub, and you think: *why am I handing all of this to someone else?* For me, that moment came at 2 AM on a Tuesday, staring at a Terms of Service update email that basically said 'we own your soul now.'\n\nSo I did what any rational person would do. I bought a VPS and started building.\n\n## The Stack\n\nThe foundation is a Debian box sitting in a data centre somewhere in Europe. On top of that:\n\n- **Gitea** for git hosting — lightweight, fast, and mine\n- **Plex** for media — because why pay for streaming when you have a 2TB drive\n- **Nginx** as the reverse proxy tying it all together\n- **Docker** containers for everything that can be containerised\n- **WireGuard** for secure remote access\n\nThe whole thing runs on 4GB of RAM and barely breaks a sweat.\n\n## The Hard Parts\n\nDNS was a nightmare at first. Getting wildcard SSL certs with Let's Encrypt took three attempts and a lot of swearing. Docker networking still makes me want to throw my laptop out the window sometimes.\n\nBut the worst part? Email. Self-hosted email is a rabbit hole I'm still climbing out of. Every major provider treats your IP as spam by default. You need SPF, DKIM, DMARC, a reverse DNS entry, and probably a blood sacrifice to get Gmail to accept your messages.\n\n## Was It Worth It?\n\nAbsolutely. I own my data. I control my infrastructure. When a service goes down, it's my fault and I can fix it. There's something deeply satisfying about `ssh root@mybox` and knowing that everything running on that machine is mine.\n\nThe total cost? About £8 a month for the VPS. That's less than a single streaming subscription.\n\n## What's Next\n\nI'm looking at adding a self-hosted AI inference server, a personal search engine, and maybe a Matrix server for comms. The empire keeps growing.", + "tags": ["self-hosted", "infrastructure", "linux", "docker"], + "mood": 4, + "energy": 5, + "motivation": 5, + "heart_rate": 82, + "threat_level": "LOW", + "coffee": 3, + "focus": 4, + "difficulty": 3, + "time_written": "02:34 AM", + "word_count": 347 + }, + { + "id": 2, + "title": "Securing the Perimeter: VPS Hardening 101", + "slug": "vps-hardening-101", + "date": "2026-03-25", + "excerpt": "Your fresh VPS is a sitting duck. Here's how I lock mine down — SSH keys, fail2ban, firewalls, and a healthy dose of paranoia.", + "content": "## You Just Deployed a Server. You're Already Under Attack.\n\nI'm not being dramatic. Within five minutes of spinning up a fresh VPS, check your auth logs. You'll see hundreds of brute-force SSH attempts from IPs all over the world. Bots are scanning every IP range constantly, looking for default credentials and open ports.\n\nWelcome to the internet.\n\n## Step 1: SSH Hardening\n\nFirst thing, always:\n\n```bash\n# Generate a key pair on your local machine\nssh-keygen -t ed25519 -C \"your@email.com\"\n\n# Copy it to the server\nssh-copy-id root@your-server\n\n# Then disable password auth\nsed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config\nsystemctl restart sshd\n```\n\nChange the default SSH port while you're at it. Security through obscurity isn't real security, but it cuts the noise in your logs by 95%.\n\n## Step 2: Firewall\n\nUFW is your friend:\n\n```bash\nufw default deny incoming\nufw default allow outgoing\nufw allow 2222/tcp # your SSH port\nufw allow 80/tcp\nufw allow 443/tcp\nufw enable\n```\n\nThat's it. Everything else gets dropped.\n\n## Step 3: Fail2Ban\n\nFail2ban watches your logs and auto-bans IPs that fail authentication too many times. Install it, configure it for SSH, and forget about it. It'll do its job quietly in the background.\n\n## Step 4: Unattended Upgrades\n\nSecurity patches should install themselves. You shouldn't need to remember to run `apt update` every day:\n\n```bash\napt install unattended-upgrades\ndpkg-reconfigure unattended-upgrades\n```\n\n## Step 5: The Paranoia Layer\n\n- Disable root login over SSH (use a regular user + sudo)\n- Set up 2FA with Google Authenticator PAM module\n- Monitor with Netdata or similar\n- Regular `lynis audit system` scans\n- Keep backups. Always keep backups.\n\n## The Mindset\n\nSecurity isn't a destination, it's a process. You're never 'done' securing a server. New vulnerabilities drop daily. The trick is making your box harder to crack than the next one. Attackers are lazy — they'll move on to easier targets.", + "tags": ["security", "linux", "sysadmin", "hardening"], + "mood": 3, + "energy": 4, + "motivation": 5, + "heart_rate": 91, + "threat_level": "HIGH", + "coffee": 4, + "focus": 5, + "difficulty": 4, + "time_written": "11:47 PM", + "word_count": 312 + }, + { + "id": 3, + "title": "AI Agents at 3AM: Building Agent Zero", + "slug": "ai-agents-3am", + "date": "2026-03-20", + "excerpt": "What happens when you give an AI root access to your server and tell it to build things? Chaos, learning, and surprisingly good code.", + "content": "## The Rabbit Hole\n\nIt started with a simple question: what if an AI could actually *do* things on my server, not just talk about doing them?\n\nI'd been messing around with LLMs for months — ChatGPT, Claude, local models on my GPU. They're brilliant at explaining things and writing snippets, but there's always that gap between 'here's the code' and 'it's actually running.' You still have to copy, paste, debug, fix the hallucinated import, debug again.\n\nThen I found Agent Zero.\n\n## What Is It?\n\nAgent Zero is an AI agent framework. You give it access to a terminal, a browser, memory, and tools. Then you tell it what you want. It figures out the steps, writes the code, runs it, checks the output, and iterates until it works.\n\nThe first time I watched it SSH into my VPS, install nginx, write a config file, test it, and reload the service — all without me touching the keyboard — I felt something between excitement and genuine unease.\n\n## The Good\n\n- It handles complex multi-step tasks that would take me an hour of googling\n- It remembers previous solutions and applies them to new problems\n- It can debug its own mistakes (most of the time)\n- It never gets frustrated at 3 AM\n\n## The Bad\n\n- It sometimes gets stuck in loops, trying the same failing approach repeatedly\n- Token costs add up fast on complex tasks\n- You need to be specific — vague instructions produce vague results\n- It occasionally decides to 'improve' things you didn't ask it to touch\n\n## The Philosophy\n\nThere's a real debate about whether giving AI tools like these is a good idea. I get it. But here's my take: these tools exist. They're getting better. The question isn't whether people will use them, it's whether you'll understand how they work when they become mainstream.\n\nI'd rather be the person who built with the early versions than the person trying to catch up later.\n\n## Current Setup\n\nMy Agent Zero instance runs in Docker on the VPS. It has access to the terminal, can browse the web, and maintains long-term memory. I use it for everything from server maintenance to building websites.\n\nYes, including this one.", + "tags": ["ai", "agent-zero", "automation", "development"], + "mood": 5, + "energy": 3, + "motivation": 4, + "heart_rate": 76, + "threat_level": "MED", + "coffee": 5, + "focus": 3, + "difficulty": 4, + "time_written": "03:22 AM", + "word_count": 340 + }, + { + "id": 4, + "title": "The 4AM Deploy: When Everything Goes Wrong", + "slug": "4am-deploy", + "date": "2026-03-15", + "excerpt": "A war story about a production deploy that went sideways, the frantic debugging that followed, and what I learned from nuking my own DNS.", + "content": "## It Was Supposed To Be Quick\n\nFamous last words. All I wanted to do was update the nginx config to add a new subdomain. Five minutes, tops. I'd done it a hundred times before.\n\n```bash\nnginx -t && systemctl reload nginx\n```\n\nExcept this time, `nginx -t` returned an error I'd never seen. Something about a duplicate server name. I'd accidentally created a circular include that referenced itself through a symlink.\n\n## The Cascade\n\nIn my sleep-deprived wisdom, I decided to 'fix' it by removing what I thought was the duplicate config file. Turns out, that was the main config for jaeswift.xyz. Not the duplicate. The main one.\n\nNginx went down. All services went dark. Git, Plex, the docs site, everything. At 4 AM.\n\n## The Panic\n\nMy phone started buzzing. Uptime monitors screaming. I tried to restore from the backup. The backup was three days old because I'd been meaning to set up daily snapshots but hadn't got round to it.\n\nThree days of config changes, gone.\n\n## The Recovery\n\nI spent the next two hours reconstructing the nginx config from memory and bash history. `history | grep nginx` became my best friend. I found most of the server blocks in various terminal scrollback sessions.\n\nBy 6 AM, everything was back online. Mostly. The Plex config took another day to get right because I'd customised the proxy headers and couldn't remember the exact settings.\n\n## The Lessons\n\n1. **Backup your configs to git.** Not tomorrow. Now.\n2. **Never deploy after midnight.** Your brain is lying to you about how awake you are.\n3. **Test in staging.** I know, I know. But actually do it.\n4. **Document everything.** Past-you is the best resource for future-you, but only if past-you wrote things down.\n5. **Set up automated backups.** I now have hourly snapshots of all critical configs.\n\n## Silver Lining\n\nThe reconstructed config was actually cleaner than the original. Sometimes you need to burn it down to build it better.\n\nBut maybe not at 4 AM.", + "tags": ["devops", "war-story", "nginx", "lessons"], + "mood": 2, + "energy": 1, + "motivation": 2, + "heart_rate": 112, + "threat_level": "CRITICAL", + "coffee": 5, + "focus": 2, + "difficulty": 5, + "time_written": "04:13 AM", + "word_count": 338 + } +] diff --git a/api/data/settings.json b/api/data/settings.json new file mode 100644 index 0000000..de935bc --- /dev/null +++ b/api/data/settings.json @@ -0,0 +1,17 @@ +{ + "widgets": { + "map": true, + "server_metrics": true, + "network_graph": true, + "power": true, + "containers": true, + "weather": true, + "now_playing": true, + "git_activity": true, + "services": true, + "threat_feed": true + }, + "site_title": "JAESWIFT", + "site_tagline": "Developer // Tinkerer // Builder", + "blog_enabled": true +} diff --git a/api/data/tracks.json b/api/data/tracks.json new file mode 100644 index 0000000..7300f0d --- /dev/null +++ b/api/data/tracks.json @@ -0,0 +1,37 @@ +[ + {"artist": "J Cole", "track": "No Role Modelz", "album": "2014 Forest Hills Drive"}, + {"artist": "J Cole", "track": "Middle Child", "album": "Revenge of the Dreamers III"}, + {"artist": "J Cole", "track": "January 28th", "album": "2014 Forest Hills Drive"}, + {"artist": "Kendrick Lamar", "track": "Money Trees", "album": "good kid, m.A.A.d city"}, + {"artist": "Kendrick Lamar", "track": "Sing About Me, I'm Dying of Thirst", "album": "good kid, m.A.A.d city"}, + {"artist": "Kendrick Lamar", "track": "United in Grief", "album": "Mr. Morale & The Big Steppers"}, + {"artist": "Travis Scott", "track": "90210", "album": "Rodeo"}, + {"artist": "Travis Scott", "track": "STARGAZING", "album": "ASTROWORLD"}, + {"artist": "Mac Miller", "track": "Self Care", "album": "Swimming"}, + {"artist": "Mac Miller", "track": "2009", "album": "Swimming"}, + {"artist": "Mac Miller", "track": "Objects in the Mirror", "album": "Watching Movies with the Sound Off"}, + {"artist": "Tyler, the Creator", "track": "EARFQUAKE", "album": "IGOR"}, + {"artist": "Tyler, the Creator", "track": "NEW MAGIC WAND", "album": "IGOR"}, + {"artist": "A$AP Rocky", "track": "Everyday", "album": "AT.LONG.LAST.A$AP"}, + {"artist": "A$AP Rocky", "track": "L$D", "album": "AT.LONG.LAST.A$AP"}, + {"artist": "JID", "track": "Surpass", "album": "The Forever Story"}, + {"artist": "JID", "track": "Dance Now", "album": "The Forever Story"}, + {"artist": "Joey Bada$$", "track": "Devastated", "album": "ALL-AMERIKKKAN BADA$$"}, + {"artist": "Joey Bada$$", "track": "Waves", "album": "2000"}, + {"artist": "Nas", "track": "N.Y. State of Mind", "album": "Illmatic"}, + {"artist": "Nas", "track": "The World Is Yours", "album": "Illmatic"}, + {"artist": "MF DOOM", "track": "Accordion", "album": "Madvillainy"}, + {"artist": "MF DOOM", "track": "Rhinestone Cowboy", "album": "MM..FOOD"}, + {"artist": "Nujabes", "track": "Feather", "album": "Modal Soul"}, + {"artist": "Nujabes", "track": "Aruarian Dance", "album": "Samurai Champloo OST"}, + {"artist": "Nujabes", "track": "Luv(sic) Part 3", "album": "Modal Soul"}, + {"artist": "jinsang", "track": "Summer's Day", "album": "Life"}, + {"artist": "jinsang", "track": "Solitude", "album": "Solitude"}, + {"artist": "idealism", "track": "Contrails", "album": "Contrails"}, + {"artist": "idealism", "track": "Distant Memory", "album": "Distant Memory"}, + {"artist": "tomppabeats", "track": "Monday Loop", "album": "Harbor LP"}, + {"artist": "tomppabeats", "track": "You and the Rain", "album": "Harbor LP"}, + {"artist": "Knxwledge", "track": "So[rt]", "album": "Hud Dreems"}, + {"artist": "Sampha", "track": "Blood on Me", "album": "Process"}, + {"artist": "Isaiah Rashad", "track": "Headshots (4r Da Locals)", "album": "The House Is Burning"} +] diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..4e00b20 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-cors +PyJWT +requests diff --git a/blog.html b/blog.html new file mode 100644 index 0000000..dd1db3d --- /dev/null +++ b/blog.html @@ -0,0 +1,84 @@ + + + + + + JAESWIFT // BLOG + + + + + + + +
+
+ + + + + +
+
+
TRANSMISSION LOG
+

BLOG // DISPATCHES

+

> Thoughts, tutorials, and war stories from the terminal.

+
+
+ + +
+
+
+ + + + + +
+ +
+ +
+
+ LOADING TRANSMISSIONS... +
+
+
+
+ + +
+ +
+ + + + diff --git a/css/blog.css b/css/blog.css new file mode 100644 index 0000000..477ccca --- /dev/null +++ b/css/blog.css @@ -0,0 +1,574 @@ +/* ============================ + BLOG PAGE STYLES + ============================ */ + +/* Header */ +.blog-header { + padding: calc(var(--nav-height) + 4rem) 2rem 3rem; + text-align: center; + position: relative; +} + +.blog-header-label { + font-family: var(--font-mono); + font-size: 0.6rem; + letter-spacing: 4px; + color: var(--text-secondary); + margin-bottom: 1rem; +} + +.blog-header-title { + font-family: var(--font-display); + font-size: clamp(2rem, 5vw, 3.5rem); + font-weight: 900; + letter-spacing: 6px; + color: var(--text-primary); + margin-bottom: 1rem; +} + +.blog-header-title .accent { color: var(--accent); } + +.blog-header-sub { + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--accent); + opacity: 0.7; +} + +/* Filters */ +.blog-filters { + display: flex; + justify-content: center; + gap: 0.5rem; + margin-bottom: 2.5rem; + flex-wrap: wrap; +} + +.filter-btn { + font-family: var(--font-mono); + font-size: 0.6rem; + letter-spacing: 2px; + padding: 0.5rem 1.2rem; + background: rgba(0, 255, 200, 0.03); + border: 1px solid var(--border); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; +} + +.filter-btn:hover, +.filter-btn.active { + background: rgba(0, 255, 200, 0.08); + border-color: var(--accent); + color: var(--accent); + box-shadow: 0 0 15px rgba(0, 255, 200, 0.1); +} + +/* Blog Section */ +.blog-section { + padding: 0 2rem 4rem; +} + +.blog-container { + max-width: 1100px; + margin: 0 auto; +} + +.blog-posts-grid { + display: flex; + flex-direction: column; + gap: 2rem; +} + +/* Loading */ +.blog-loading { + text-align: center; + padding: 3rem; + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-secondary); + letter-spacing: 2px; +} + +.loading-spinner { + width: 30px; + height: 30px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 1rem; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ============================ + POST CARD + ============================ */ +.post-card { + display: grid; + grid-template-columns: 1fr 200px; + border: 1px solid var(--border); + background: rgba(10, 14, 20, 0.6); + position: relative; + overflow: hidden; + cursor: pointer; + transition: all 0.4s ease; +} + +.post-card::before { + content: ''; + position: absolute; + top: 0; left: 0; + width: 20px; height: 20px; + border-top: 2px solid var(--accent); + border-left: 2px solid var(--accent); + pointer-events: none; + z-index: 1; +} + +.post-card::after { + content: ''; + position: absolute; + bottom: 0; right: 0; + width: 20px; height: 20px; + border-bottom: 2px solid var(--accent); + border-right: 2px solid var(--accent); + pointer-events: none; + z-index: 1; +} + +.post-card:hover { + border-color: rgba(0, 255, 200, 0.3); + box-shadow: 0 0 30px rgba(0, 255, 200, 0.05), inset 0 0 30px rgba(0, 255, 200, 0.02); + transform: translateY(-2px); +} + +/* Left: Content */ +.post-card-content { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.post-card-meta { + display: flex; + align-items: center; + gap: 1rem; + font-family: var(--font-mono); + font-size: 0.55rem; + letter-spacing: 1px; +} + +.post-date { + color: var(--text-secondary); +} + +.post-threat { + padding: 0.15rem 0.5rem; + font-size: 0.5rem; + letter-spacing: 2px; + font-weight: 700; +} + +.threat-LOW { background: rgba(0, 255, 200, 0.1); color: var(--accent); border: 1px solid rgba(0, 255, 200, 0.2); } +.threat-MED { background: rgba(255, 165, 2, 0.1); color: #ffa502; border: 1px solid rgba(255, 165, 2, 0.2); } +.threat-HIGH { background: rgba(255, 71, 87, 0.1); color: #ff4757; border: 1px solid rgba(255, 71, 87, 0.2); } +.threat-CRITICAL { background: rgba(255, 0, 0, 0.15); color: #ff0040; border: 1px solid rgba(255, 0, 0, 0.3); animation: criticalPulse 2s ease infinite; } + +@keyframes criticalPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.post-time { + color: var(--accent); + opacity: 0.5; +} + +.post-card-title { + font-family: var(--font-display); + font-size: 1.3rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: 1px; + transition: color 0.3s; +} + +.post-card:hover .post-card-title { color: var(--accent); } + +.post-card-excerpt { + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.7; + color: var(--text-secondary); +} + +.post-card-tags { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + margin-top: auto; +} + +.post-tag { + font-family: var(--font-mono); + font-size: 0.5rem; + letter-spacing: 1px; + padding: 0.2rem 0.6rem; + border: 1px solid var(--border); + color: var(--text-secondary); + text-transform: uppercase; +} + +/* Right: Stat Bars */ +.post-card-stats { + padding: 1rem; + border-left: 1px solid var(--border); + background: rgba(0, 255, 200, 0.015); + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.post-card-stats .stats-header { + font-family: var(--font-mono); + font-size: 0.5rem; + letter-spacing: 2px; + color: var(--text-secondary); + text-align: center; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--border); + margin-bottom: 0.25rem; +} + +.stat-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.stat-row-label { + font-family: var(--font-mono); + font-size: 0.5rem; + letter-spacing: 1px; + color: var(--text-secondary); + width: 55px; + flex-shrink: 0; +} + +.stat-bar-container { + flex: 1; + display: flex; + gap: 3px; +} + +.stat-pip { + flex: 1; + height: 8px; + background: rgba(0, 255, 200, 0.06); + border: 1px solid rgba(0, 255, 200, 0.1); + transition: all 0.5s ease; +} + +.stat-pip.filled { + background: var(--accent); + box-shadow: 0 0 6px var(--accent-glow); + border-color: var(--accent); +} + +.stat-pip.filled.warn { + background: #ffa502; + box-shadow: 0 0 6px rgba(255, 165, 2, 0.4); + border-color: #ffa502; +} + +.stat-pip.filled.danger { + background: #ff4757; + box-shadow: 0 0 6px rgba(255, 71, 87, 0.4); + border-color: #ff4757; +} + +.stat-bpm { + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--accent); + text-shadow: 0 0 6px var(--accent-glow); + text-align: right; +} + +.stat-bpm-label { + font-size: 0.45rem; + color: var(--text-secondary); + letter-spacing: 1px; +} + +.stat-coffee { + font-size: 0.6rem; + letter-spacing: 2px; +} + +/* ============================ + SINGLE POST PAGE + ============================ */ +.post-page-header { + padding: calc(var(--nav-height) + 3rem) 2rem 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.post-back { + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--text-secondary); + text-decoration: none; + letter-spacing: 2px; + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 2rem; + transition: color 0.3s; +} + +.post-back:hover { color: var(--accent); } + +.post-page-layout { + max-width: 1100px; + margin: 0 auto; + padding: 0 2rem 4rem; + display: grid; + grid-template-columns: 1fr 260px; + gap: 2rem; +} + +/* Post Content */ +.post-body { + font-family: var(--font-mono); + font-size: 0.85rem; + line-height: 1.9; + color: var(--text-secondary); +} + +.post-body h2 { + font-family: var(--font-display); + font-size: 1.3rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: 2px; + margin: 2.5rem 0 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.post-body h2::before { + content: '// '; + color: var(--accent); + opacity: 0.5; +} + +.post-body p { margin-bottom: 1.2rem; } + +.post-body strong { color: var(--text-primary); } + +.post-body em { color: var(--accent); font-style: italic; opacity: 0.8; } + +.post-body ul, .post-body ol { + margin: 1rem 0; + padding-left: 1.5rem; +} + +.post-body li { + margin-bottom: 0.5rem; + position: relative; +} + +.post-body ul li::marker { color: var(--accent); } + +.post-body code { + background: rgba(0, 255, 200, 0.05); + border: 1px solid var(--border); + padding: 0.15rem 0.4rem; + font-size: 0.8rem; + color: var(--accent); +} + +.post-body pre { + background: rgba(6, 6, 8, 0.8); + border: 1px solid var(--border); + padding: 1.25rem; + overflow-x: auto; + margin: 1.5rem 0; + position: relative; +} + +.post-body pre::before { + content: 'TERMINAL'; + position: absolute; + top: 0; right: 0; + font-family: var(--font-mono); + font-size: 0.45rem; + letter-spacing: 2px; + color: var(--text-secondary); + padding: 0.3rem 0.6rem; + background: rgba(0, 255, 200, 0.03); + border-left: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.post-body pre code { + background: none; + border: none; + padding: 0; + color: var(--accent); + font-size: 0.78rem; + line-height: 1.6; +} + +/* Stat Sidebar */ +.post-stat-sidebar { + position: sticky; + top: calc(var(--nav-height) + 2rem); + display: flex; + flex-direction: column; + gap: 1rem; +} + +.sidebar-panel { + border: 1px solid var(--border); + background: rgba(10, 14, 20, 0.6); + position: relative; +} + +.sidebar-panel::before { + content: ''; + position: absolute; + top: 0; left: 0; + width: 14px; height: 14px; + border-top: 2px solid var(--accent); + border-left: 2px solid var(--accent); + pointer-events: none; +} + +.sidebar-panel::after { + content: ''; + position: absolute; + bottom: 0; right: 0; + width: 14px; height: 14px; + border-bottom: 2px solid var(--accent); + border-right: 2px solid var(--accent); + pointer-events: none; +} + +.sidebar-header { + font-family: var(--font-mono); + font-size: 0.5rem; + letter-spacing: 3px; + color: var(--text-secondary); + padding: 0.6rem 0.8rem; + border-bottom: 1px solid var(--border); + background: rgba(0, 255, 200, 0.02); +} + +.sidebar-body { + padding: 0.8rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.sidebar-stat-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar-stat-label { + font-family: var(--font-mono); + font-size: 0.55rem; + letter-spacing: 1px; + color: var(--text-secondary); +} + +.sidebar-stat-bars { + display: flex; + gap: 3px; +} + +.sidebar-pip { + width: 16px; + height: 10px; + background: rgba(0, 255, 200, 0.06); + border: 1px solid rgba(0, 255, 200, 0.1); +} + +.sidebar-pip.filled { + background: var(--accent); + box-shadow: 0 0 6px var(--accent-glow); + border-color: var(--accent); +} + +.sidebar-bpm-display { + text-align: center; + padding: 0.5rem; +} + +.sidebar-bpm-val { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 900; + color: var(--accent); + text-shadow: 0 0 15px var(--accent-glow); + line-height: 1; +} + +.sidebar-bpm-unit { + font-family: var(--font-mono); + font-size: 0.5rem; + letter-spacing: 2px; + color: var(--text-secondary); + display: block; + margin-top: 0.3rem; +} + +.sidebar-meta-item { + display: flex; + justify-content: space-between; + font-family: var(--font-mono); + font-size: 0.6rem; +} + +.sidebar-meta-label { color: var(--text-secondary); letter-spacing: 1px; } +.sidebar-meta-value { color: var(--accent); } + +/* ============================ + RESPONSIVE + ============================ */ +@media (max-width: 768px) { + .post-card { + grid-template-columns: 1fr; + } + .post-card-stats { + border-left: none; + border-top: 1px solid var(--border); + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + } + .post-card-stats .stats-header { + width: 100%; + } + .stat-row { min-width: 120px; } + .post-page-layout { + grid-template-columns: 1fr; + } + .post-stat-sidebar { + position: static; + flex-direction: row; + flex-wrap: wrap; + } + .sidebar-panel { flex: 1; min-width: 200px; } +} diff --git a/js/blog.js b/js/blog.js new file mode 100644 index 0000000..0312ee7 --- /dev/null +++ b/js/blog.js @@ -0,0 +1,185 @@ +/* =================================================== + JAESWIFT BLOG — Post loader + Cyberpunk stats + =================================================== */ +(function () { + 'use strict'; + + const API = window.location.hostname === 'localhost' + ? 'http://localhost:5000' + : '/api'; + + // ─── Clock ─── + function initClock() { + const el = document.getElementById('navClock'); + if (!el) return; + const tick = () => { + const d = new Date(); + el.textContent = d.toLocaleTimeString('en-GB', { hour12: false }) + ' UTC' + (d.getTimezoneOffset() <= 0 ? '+' : '') + (-d.getTimezoneOffset() / 60); + }; + tick(); + setInterval(tick, 1000); + } + + // ─── Navbar ─── + function initNavbar() { + const toggle = document.getElementById('navToggle'); + const menu = document.getElementById('navMenu'); + if (toggle && menu) { + toggle.addEventListener('click', () => menu.classList.toggle('active')); + } + window.addEventListener('scroll', () => { + document.getElementById('navbar')?.classList.toggle('scrolled', window.scrollY > 50); + }, { passive: true }); + } + + // ─── Build Stat Pips ─── + function buildPips(val, max = 5) { + let html = '
'; + for (let i = 0; i < max; i++) { + const filled = i < val; + let cls = 'stat-pip'; + if (filled) { + cls += ' filled'; + if (val <= 2) cls += ' danger'; + else if (val <= 3) cls += ' warn'; + } + html += `
`; + } + html += '
'; + return html; + } + + // ─── Coffee Icons ─── + function buildCoffee(val) { + return '' + '☕'.repeat(val) + '' + '☕'.repeat(5 - val) + ''; + } + + // ─── Render Post Card ─── + function renderPostCard(post) { + return ` +
+
+
+ + ${post.threat_level} + ${post.time_written || ''} +
+

${post.title}

+

${post.excerpt}

+
+ ${(post.tags || []).map(t => ``).join('')} +
+
+
+
OPERATOR STATUS
+
+ MOOD + ${buildPips(post.mood)} +
+
+ ENERGY + ${buildPips(post.energy)} +
+
+ MOTIVE + ${buildPips(post.motivation)} +
+
+ FOCUS + ${buildPips(post.focus)} +
+
+
${post.heart_rate} BPM
+
+
+ ${buildCoffee(post.coffee)} +
+
+
`; + } + + // ─── Load Posts ─── + async function loadPosts() { + const grid = document.getElementById('blogPosts'); + if (!grid) return; + + try { + const res = await fetch(API + '/posts'); + if (!res.ok) throw new Error('API error'); + const posts = await res.json(); + + if (posts.length === 0) { + grid.innerHTML = '
NO TRANSMISSIONS FOUND
'; + return; + } + + // Sort by date descending + posts.sort((a, b) => new Date(b.date) - new Date(a.date)); + grid.innerHTML = posts.map(renderPostCard).join(''); + + // Animate cards in + const cards = grid.querySelectorAll('.post-card'); + cards.forEach((card, i) => { + card.style.opacity = '0'; + card.style.transform = 'translateY(20px)'; + card.style.transition = 'all 0.5s ease'; + setTimeout(() => { + card.style.opacity = '1'; + card.style.transform = 'translateY(0)'; + }, 100 + i * 150); + }); + + } catch (err) { + // Fallback: load from static JSON + try { + const res2 = await fetch('api/data/posts.json'); + const posts = await res2.json(); + posts.sort((a, b) => new Date(b.date) - new Date(a.date)); + grid.innerHTML = posts.map(renderPostCard).join(''); + const cards = grid.querySelectorAll('.post-card'); + cards.forEach((card, i) => { + card.style.opacity = '0'; + card.style.transform = 'translateY(20px)'; + card.style.transition = 'all 0.5s ease'; + setTimeout(() => { + card.style.opacity = '1'; + card.style.transform = 'translateY(0)'; + }, 100 + i * 150); + }); + } catch (e2) { + grid.innerHTML = '
TRANSMISSION ERROR — RETRY LATER
'; + } + } + } + + // ─── Filters ─── + function initFilters() { + const btns = document.querySelectorAll('.filter-btn'); + btns.forEach(btn => { + btn.addEventListener('click', () => { + btns.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const filter = btn.dataset.filter; + const cards = document.querySelectorAll('.post-card'); + cards.forEach(card => { + const tags = card.dataset.tags || ''; + if (filter === 'all' || tags.includes(filter)) { + card.style.display = ''; + card.style.opacity = '1'; + } else { + card.style.opacity = '0'; + setTimeout(() => { card.style.display = 'none'; }, 300); + } + }); + }); + }); + } + + // ─── Init ─── + document.addEventListener('DOMContentLoaded', () => { + initClock(); + initNavbar(); + loadPosts(); + initFilters(); + }); +})();