QoL update

This commit is contained in:
Ben Mosley
2026-04-22 22:09:08 -05:00
parent 4d091a15cd
commit 7c9b904985
15 changed files with 449 additions and 291 deletions

212
app.py
View File

@@ -4,25 +4,19 @@ from sqlalchemy import func
from dotenv import load_dotenv
import os
from werkzeug.utils import secure_filename
from flask_login import LoginManager, login_required, logout_user, UserMixin, login_user
from flask_login import LoginManager, login_required, logout_user, UserMixin, login_user, current_user
from flask_mail import Mail, Message
import json
from slugify import slugify
from datetime import datetime
import hmac, hashlib, json, time
from pathlib import Path
import json, hmac, hashlib, requests, time
import hmac, hashlib, json, requests, time
import markdown as md_lib
load_dotenv(Path(__file__).with_name(".env"))
app = Flask(__name__, static_folder="static", static_url_path="/static")
HUB_PUSH_SECRET = os.getenv("HUB_PUSH_SECRET") # match the api_secret you set in the Hub
HUB_PUSH_SECRET = os.getenv("HUB_PUSH_SECRET")
CONTACT = {
"name": os.environ.get("BH_CONTACT_NAME", "Benjamin Mosley"),
@@ -60,10 +54,8 @@ def load_user(user_id):
return User()
return None
@app.context_processor
def inject_now():
# lets you use {{ now().year }} in any template
return {"now": datetime.now}
class BlogPost(db.Model):
@@ -75,6 +67,7 @@ class BlogPost(db.Model):
category = db.Column(db.String(100), nullable=True)
tags = db.Column(db.Text, nullable=True)
pinned = db.Column(db.Boolean, default=False)
draft = db.Column(db.Boolean, default=False)
class ProjectPost(db.Model):
id = db.Column(db.Integer, primary_key=True)
@@ -85,9 +78,17 @@ class ProjectPost(db.Model):
category = db.Column(db.String(100), nullable=True)
tags = db.Column(db.Text, nullable=True)
pinned = db.Column(db.Boolean, default=False)
draft = db.Column(db.Boolean, default=False)
with app.app_context():
db.create_all()
with db.engine.connect() as conn:
for table in ('blog_post', 'project_post'):
try:
conn.execute(db.text(f'ALTER TABLE {table} ADD COLUMN draft BOOLEAN NOT NULL DEFAULT 0'))
conn.commit()
except Exception:
pass
@app.template_filter('from_json')
def from_json(value):
@@ -96,71 +97,31 @@ def from_json(value):
except (TypeError, json.JSONDecodeError):
return []
@app.template_filter('markdown')
def render_markdown(value):
return md_lib.markdown(value or '', extensions=['fenced_code', 'tables', 'nl2br'])
def push_to_domain(push_url: str, api_secret: str, payload: dict):
# Serialize deterministically
body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
# Compute signature over the exact bytes
sig = hmac.new(api_secret.encode('utf-8'), body, hashlib.sha256).hexdigest()
headers = {
"Content-Type": "application/json",
"X-Hub-Signature-256": f"sha256={sig}",
# Optional if you later check timestamps:
# "X-Timestamp": str(int(time.time())),
}
# Send the *exact same* bytes you signed
r = requests.post(push_url, headers=headers, data=body, timeout=15)
return r
return requests.post(push_url, headers=headers, data=body, timeout=15)
def _verify_hub_signature(raw: bytes) -> None:
if not HUB_PUSH_SECRET:
abort(403, description="Missing HUB_PUSH_SECRET")
# Accept both header names
sig_hdr = (request.headers.get("X-Signature")
or request.headers.get("X-Hub-Signature-256")
or "").strip()
# Accept either "sha256=<hex>" or just "<hex>"
provided = sig_hdr
if provided.lower().startswith("sha256="):
provided = provided.split("=", 1)[1].strip()
if not provided or any(c.isspace() for c in provided):
abort(403, description="Bad signature format")
expected = hmac.new(HUB_PUSH_SECRET.encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(provided, expected):
abort(403, description="Signature mismatch")
ts = request.headers.get("X-Timestamp")
if ts and ts.isdigit() and abs(time.time() - int(ts)) > 300:
abort(403, description="Stale request")
def _to_slug(text: str) -> str:
s = slugify(text or "")
return s or f"post-{int(time.time())}"
def _to_slug(text: str) -> str:
s = slugify(text or "")
return s or f"post-{int(time.time())}"
def _verify_hub_signature(raw: bytes) -> None:
if not HUB_PUSH_SECRET:
abort(403, description="Missing HUB_PUSH_SECRET")
# Accept both header names
sig_hdr = (request.headers.get("X-Signature")
or request.headers.get("X-Hub-Signature-256")
or "").strip()
# Accept either "sha256=<hex>" or just "<hex>"
provided = sig_hdr
if provided.lower().startswith("sha256="):
provided = provided.split("=", 1)[1].strip()
# must be 64 hex chars, no spaces
if not provided or any(c.isspace() for c in provided) or len(provided) != 64:
abort(403, description="Bad signature format")
@@ -172,7 +133,6 @@ def _verify_hub_signature(raw: bytes) -> None:
if ts and ts.isdigit() and abs(time.time() - int(ts)) > 300:
abort(403, description="Stale request")
def _to_slug(text: str) -> str:
s = slugify(text or "")
return s or f"post-{int(time.time())}"
@@ -188,74 +148,44 @@ def api_publish():
kind = (data.get("kind") or "blog").lower()
title = data.get("title") or "Untitled"
summary = data.get("summary")
content_html = data.get("content_html") or ""
content_text = data.get("content_text") or ""
tags = ",".join(data.get("tags") or [])
images = data.get("images") or []
pinned = bool(data.get("pinned"))
external_id = data.get("external_id") # optional stable id (slug-like)
external_id = data.get("external_id")
slug = _to_slug(external_id or title)
# Normalize content to your existing schema
body = content_html or content_text.replace("\n", "<br>")
images_json = json.dumps(images) if images else None
if kind == "project":
obj = ProjectPost(
title=title,
slug=slug,
content=body,
category=None,
tags=tags,
pinned=pinned,
images=images_json
title=title, slug=slug, content=body,
category=None, tags=tags, pinned=pinned, images=images_json
)
else:
obj = BlogPost(
title=title,
slug=slug,
content=body,
category=None,
tags=tags,
pinned=pinned,
images=images_json
title=title, slug=slug, content=body,
category=None, tags=tags, pinned=pinned, images=images_json
)
db.session.add(obj)
db.session.commit()
# Return the canonical URL so the hub can log it if you want
url = url_for("view_project" if kind == "project" else "view_blog", slug=slug, _external=True)
return jsonify({"ok": True, "url": url, "id": obj.id, "slug": slug})
computed = hmac.new(
(HUB_PUSH_SECRET or "").encode(), raw, hashlib.sha256
).hexdigest()
return jsonify({
"saw_header": sig_hdr,
"provided": provided,
"len_provided": len(provided),
"computed": computed,
"matches": hmac.compare_digest(provided, computed),
"body_len": len(raw),
})
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form["username"]
password = request.form["password"]
if username == USERNAME and password == PASSWORD:
user = User()
login_user(user)
session.permanent = True
return redirect(url_for("admin_panel"))
flash("Invalid email or password!", "danger")
return render_template("login.html")
@@ -265,7 +195,6 @@ def logout():
logout_user()
return redirect(url_for("index"))
@app.route('/admin')
@login_required
def admin_panel():
@@ -278,10 +207,11 @@ def admin_panel():
def new_blog():
if request.method == 'POST':
title = request.form['title']
content = request.form['content'].replace("\n", "<br>")
content = request.form['content']
category = request.form['category']
tags = request.form['tags']
pinned = 'pinned' in request.form
is_draft = request.form.get('action') == 'draft'
images = request.files.getlist('images')
slug = slugify(title)
@@ -290,17 +220,13 @@ def new_blog():
image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
new_post = BlogPost(
title=title,
slug=slug,
content=content,
category=category,
tags=tags,
pinned=pinned,
images=json.dumps(image_filenames)
title=title, slug=slug, content=content,
category=category, tags=tags, pinned=pinned,
images=json.dumps(image_filenames), draft=is_draft
)
db.session.add(new_post)
db.session.commit()
return redirect(url_for('blog'))
return redirect(url_for('view_blog', slug=slug))
return render_template('new_blog.html')
@@ -309,10 +235,11 @@ def new_blog():
def new_project():
if request.method == 'POST':
title = request.form['title']
content = request.form['content'].replace("\n", "<br>")
content = request.form['content']
category = request.form['category']
tags = request.form['tags']
pinned = 'pinned' in request.form
is_draft = request.form.get('action') == 'draft'
images = request.files.getlist('images')
slug = slugify(title)
@@ -321,17 +248,13 @@ def new_project():
image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
new_post = ProjectPost(
title=title,
slug=slug,
content=content,
category=category,
tags=tags,
pinned=pinned,
images=json.dumps(image_filenames)
title=title, slug=slug, content=content,
category=category, tags=tags, pinned=pinned,
images=json.dumps(image_filenames), draft=is_draft
)
db.session.add(new_post)
db.session.commit()
return redirect(url_for('projects'))
return redirect(url_for('view_project', slug=slug))
return render_template('new_project.html')
@@ -339,18 +262,16 @@ def new_project():
@login_required
def edit_blog(slug):
blogpost = BlogPost.query.filter_by(slug=slug).first_or_404()
if request.method == 'POST':
blogpost.title = request.form['title']
blogpost.slug = slugify(blogpost.title)
blogpost.content = request.form['content'].replace("\n", "<br>")
blogpost.content = request.form['content']
blogpost.category = request.form['category']
blogpost.tags = request.form['tags']
blogpost.pinned = 'pinned' in request.form
images = request.files.getlist('images')
image_filenames = json.loads(blogpost.images) if blogpost.images else []
for image in images:
if image.filename:
filename = secure_filename(image.filename)
@@ -367,18 +288,16 @@ def edit_blog(slug):
@login_required
def edit_project(slug):
projectpost = ProjectPost.query.filter_by(slug=slug).first_or_404()
if request.method == 'POST':
projectpost.title = request.form['title']
projectpost.slug = slugify(projectpost.title)
projectpost.content = request.form['content'].replace("\n", "<br>")
projectpost.content = request.form['content']
projectpost.category = request.form['category']
projectpost.tags = request.form['tags']
projectpost.pinned = 'pinned' in request.form
images = request.files.getlist('images')
image_filenames = json.loads(projectpost.images) if projectpost.images else []
for image in images:
if image.filename:
filename = secure_filename(image.filename)
@@ -419,36 +338,56 @@ def delete_project(slug):
@app.route('/blogtag/<tag>')
def view_tag(tag):
tag_lower = tag.lower() # Normalize case
tag_lower = tag.lower()
blogpost = BlogPost.query.filter(func.lower(BlogPost.tags).contains(tag_lower)).order_by(BlogPost.id.desc()).all()
return render_template('blog_tag_results.html', blogpost=blogpost, tag=tag)
@app.route('/projecttag/<tag>')
def view_project_tag(tag):
tag_lower = tag.lower() # Normalize case
tag_lower = tag.lower()
projectpost = ProjectPost.query.filter(func.lower(ProjectPost.tags).contains(tag_lower)).order_by(ProjectPost.id.desc()).all()
return render_template('project_tag_results.html', projectpost=projectpost, tag=tag)
@app.route('/blog')
def blog():
blogpost = BlogPost.query.order_by(BlogPost.id.desc()).all()
blogpost = BlogPost.query.filter_by(draft=False).order_by(BlogPost.id.desc()).all()
return render_template('blog.html', blogpost=blogpost)
@app.route('/projects')
def projects():
projectpost = ProjectPost.query.order_by(ProjectPost.id.desc()).all()
projectpost = ProjectPost.query.filter_by(draft=False).order_by(ProjectPost.id.desc()).all()
return render_template('project.html', projectpost=projectpost)
@app.route('/blog/<slug>')
def view_blog(slug):
blogpost = BlogPost.query.filter_by(slug=slug).first_or_404()
if blogpost.draft and not current_user.is_authenticated:
abort(404)
return render_template('view_blog.html', blogpost=blogpost)
@app.route('/project/<slug>')
def view_project(slug):
projectpost = ProjectPost.query.filter_by(slug=slug).first_or_404()
if projectpost.draft and not current_user.is_authenticated:
abort(404)
return render_template('view_project.html', projectpost=projectpost)
@app.route('/publish-blog/<slug>', methods=['POST'])
@login_required
def publish_blog(slug):
blogpost = BlogPost.query.filter_by(slug=slug).first_or_404()
blogpost.draft = False
db.session.commit()
return redirect(url_for('view_blog', slug=slug))
@app.route('/publish-project/<slug>', methods=['POST'])
@login_required
def publish_project(slug):
projectpost = ProjectPost.query.filter_by(slug=slug).first_or_404()
projectpost.draft = False
db.session.commit()
return redirect(url_for('view_project', slug=slug))
@app.route('/contact')
def contact():
return render_template('contact.html', CONTACT=CONTACT)
@@ -465,32 +404,5 @@ def about():
def index():
return render_template('index.html')
# ---- patched helpers (appended) ----
def _verify_hub_signature(raw: bytes) -> None:
if not HUB_PUSH_SECRET:
abort(403, description="Missing HUB_PUSH_SECRET")
# Accept both header names and both formats
sig_hdr = (request.headers.get("X-Signature")
or request.headers.get("X-Hub-Signature-256")
or "").strip()
provided = sig_hdr
if provided.lower().startswith("sha256="):
provided = provided.split("=", 1)[1].strip()
# must be a 64-char hex, no whitespace
if not provided or any(c.isspace() for c in provided) or len(provided) != 64:
abort(403, description="Bad signature format")
expected = hmac.new(HUB_PUSH_SECRET.encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(provided, expected):
abort(403, description="Signature mismatch")
ts = request.headers.get("X-Timestamp")
if ts and ts.isdigit() and abs(time.time() - int(ts)) > 300:
abort(403, description="Stale request")
def _to_slug(text: str) -> str:
s = slugify(text or "")
return s or f"post-{int(time.time())}"
if __name__ == '__main__':
app.run(debug=True)

Binary file not shown.

View File

@@ -5,3 +5,5 @@ flask-mail
python-dotenv
python-slugify
werkzeug
requests
markdown

BIN
static/Headshot.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

View File

@@ -32,11 +32,21 @@
<tbody>
{% for projectpost in projectpost %}
<tr class="border-b border-[rgb(var(--border))] hover:bg-[rgb(var(--card))]/60 transition">
<td class="px-5 py-3 text-[rgb(var(--fg))] font-medium">{{ projectpost.title }}</td>
<td class="px-5 py-3 text-[rgb(var(--fg))] font-medium">
{{ projectpost.title }}
{% if projectpost.draft %}
<span class="ml-2 text-xs font-semibold bg-yellow-500/20 text-yellow-300 border border-yellow-400/40 px-2 py-0.5 rounded-full">Draft</span>
{% endif %}
</td>
<td class="px-5 py-3 text-[rgb(var(--muted))]">{{ projectpost.category }}</td>
<td class="px-5 py-3 flex flex-wrap gap-2">
<a href="{{ url_for('view_project', slug=projectpost.slug) }}" class="btn btn-ghost border-none bg-blue-600 hover:bg-blue-700 text-white">View</a>
<a href="{{ url_for('view_project', slug=projectpost.slug) }}" class="btn btn-ghost border-none bg-blue-600 hover:bg-blue-700 text-white">Preview</a>
<a href="{{ url_for('edit_project', slug=projectpost.slug) }}" class="btn btn-ghost border-none bg-yellow-500 hover:bg-yellow-600 text-white">Edit</a>
{% if projectpost.draft %}
<form action="{{ url_for('publish_project', slug=projectpost.slug) }}" method="POST">
<button type="submit" class="btn btn-ghost border-none bg-green-600 hover:bg-green-700 text-white">Publish</button>
</form>
{% endif %}
<form action="{{ url_for('delete_project', slug=projectpost.slug) }}" method="POST" onsubmit="return confirm('Delete this project?');">
<button type="submit" class="btn btn-ghost border-none bg-red-600 hover:bg-red-700 text-white">Delete</button>
</form>
@@ -74,11 +84,21 @@
<tbody>
{% for blogpost in blogpost %}
<tr class="border-b border-[rgb(var(--border))] hover:bg-[rgb(var(--card))]/60 transition">
<td class="px-5 py-3 text-[rgb(var(--fg))] font-medium">{{ blogpost.title }}</td>
<td class="px-5 py-3 text-[rgb(var(--fg))] font-medium">
{{ blogpost.title }}
{% if blogpost.draft %}
<span class="ml-2 text-xs font-semibold bg-yellow-500/20 text-yellow-300 border border-yellow-400/40 px-2 py-0.5 rounded-full">Draft</span>
{% endif %}
</td>
<td class="px-5 py-3 text-[rgb(var(--muted))]">{{ blogpost.category }}</td>
<td class="px-5 py-3 flex flex-wrap gap-2">
<a href="{{ url_for('view_blog', slug=blogpost.slug) }}" class="btn btn-ghost border-none bg-blue-600 hover:bg-blue-700 text-white">View</a>
<a href="{{ url_for('view_blog', slug=blogpost.slug) }}" class="btn btn-ghost border-none bg-blue-600 hover:bg-blue-700 text-white">Preview</a>
<a href="{{ url_for('edit_blog', slug=blogpost.slug) }}" class="btn btn-ghost border-none bg-yellow-500 hover:bg-yellow-600 text-white">Edit</a>
{% if blogpost.draft %}
<form action="{{ url_for('publish_blog', slug=blogpost.slug) }}" method="POST">
<button type="submit" class="btn btn-ghost border-none bg-green-600 hover:bg-green-700 text-white">Publish</button>
</form>
{% endif %}
<form action="{{ url_for('delete_blog', slug=blogpost.slug) }}" method="POST" onsubmit="return confirm('Delete this post?');">
<button type="submit" class="btn btn-ghost border-none bg-red-600 hover:bg-red-700 text-white">Delete</button>
</form>

View File

@@ -1,29 +1,111 @@
{% extends "base.html" %}
{% block title %}Edit Blog{% endblock %}
{% block title %}Edit — {{ blogpost.title }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
{% endblock %}
{% block content %}
<form method="POST" enctype="multipart/form-data">
<label>Title:</label>
<input type="text" name="title" value="{{ blogpost.title }}" required>
<!-- Hero -->
<section class="py-20 bg-gradient-to-br from-red-800 to-orange-400 text-white text-center">
<h1 class="text-5xl font-extrabold mb-2">Edit Blog Post</h1>
<p class="text-lg opacity-90">{{ blogpost.title }}</p>
</section>
<label>Content:</label>
<textarea name="content">{{ blogpost.content|replace('<br>', '\n') }}</textarea>
<!-- Form -->
<section class="py-16 bg-black text-white">
<div class="max-w-3xl mx-auto px-6">
<form method="POST" enctype="multipart/form-data" class="space-y-6">
<label>Category:</label>
<input type="text" name="category" value="{{ blogpost.category }}">
<!-- Title -->
<div>
<label for="title" class="block font-semibold mb-1">Post Title</label>
<input type="text" id="title" name="title" value="{{ blogpost.title }}" required
class="text-black bg-white w-full border border-gray-300 rounded px-4 py-2 focus:ring-2 focus:ring-orange-400 focus:outline-none">
</div>
<label>Tags:</label>
<input type="text" name="tags" value="{{ blogpost.tags }}">
<!-- Content -->
<div>
<label for="content" class="block font-semibold mb-1">Post Content</label>
<textarea id="content" name="content" required
class="text-black bg-white w-full border border-gray-300 rounded px-4 py-2 leading-relaxed text-base focus:ring-2 focus:ring-orange-400 focus:outline-none resize-none overflow-hidden"
rows="1">{{ blogpost.content|replace('<br>', '\n') }}</textarea>
</div>
<!-- Category -->
<div>
<label for="category" class="block font-semibold mb-1">Category</label>
<input type="text" id="category" name="category" value="{{ blogpost.category or '' }}"
placeholder="What's This Post About?"
class="text-black bg-white w-full border border-gray-300 rounded px-4 py-2 focus:ring-2 focus:ring-orange-400 focus:outline-none">
</div>
<label>Upload New Images:</label>
<input type="file" name="images" multiple>
<!-- Tags -->
<div>
<label for="tags" class="block font-semibold mb-1">Tags (comma-separated)</label>
<input type="text" id="tags" name="tags" value="{{ blogpost.tags or '' }}"
placeholder="e.g., Flask, SQLite, API"
class="text-black bg-white w-full border border-gray-300 rounded px-4 py-2 focus:ring-2 focus:ring-orange-400 focus:outline-none">
</div>
<button type="submit">Save Changes</button>
<!-- Pinned -->
<div class="flex items-center gap-2">
<input type="checkbox" id="pinned" name="pinned" {% if blogpost.pinned %}checked{% endif %}
class="w-4 h-4 accent-orange-500">
<label for="pinned" class="font-semibold">Pin this post</label>
</div>
<!-- Existing Images -->
{% if blogpost.images %}
{% set imgs = blogpost.images|from_json %}
{% if imgs %}
<div>
<p class="font-semibold mb-2">Current Images</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
{% for img in imgs %}
<div class="overflow-hidden rounded-lg border border-gray-700">
<img src="{{ url_for('static', filename='uploads/' + img) }}"
alt="{{ img }}" class="w-full h-32 object-cover">
<p class="text-xs text-gray-400 truncate px-2 py-1">{{ img }}</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
<!-- Upload New Images -->
<div>
<label for="images" class="block font-semibold mb-1">Upload New Images</label>
<input type="file" name="images" id="images" multiple
class="text-black w-full border border-gray-300 rounded px-3 py-2 text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:bg-orange-600 file:text-white hover:file:bg-orange-700">
<p class="text-xs text-gray-400 mt-1">New images are added alongside existing ones.</p>
</div>
<!-- Actions -->
<div class="pt-4 flex flex-wrap gap-3">
<button type="submit"
class="bg-orange-600 text-white px-6 py-2 rounded hover:bg-orange-700 transition">
Save Changes
</button>
<a href="{{ url_for('view_blog', slug=blogpost.slug) }}"
class="bg-gray-700 text-white px-6 py-2 rounded hover:bg-gray-600 transition">
Cancel
</a>
</div>
</form>
</div>
</section>
<script src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
<script>
new EasyMDE({
element: document.getElementById('content'),
spellChecker: false,
toolbar: ['bold','italic','heading','|','quote','unordered-list','ordered-list','|','link','image','|','preview','side-by-side','fullscreen','|','guide'],
});
</script>
{% endblock %}

View File

@@ -1,26 +1,111 @@
{% extends "base.html" %}
{% block title %}Edit Project{% endblock %}
{% block title %}Edit — {{ projectpost.title }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
{% endblock %}
{% block content %}
<form method="POST" enctype="multipart/form-data">
<label>Title:</label>
<input type="text" name="title" value="{{ projectpost.title }}" required>
<label>Content:</label>
<textarea name="content">{{ projectpost.content|replace('<br>', '\n') }}</textarea>
<!-- Hero -->
<section class="py-20 bg-gradient-to-br from-red-800 to-orange-400 text-white text-center">
<h1 class="text-5xl font-extrabold mb-2">Edit Project Post</h1>
<p class="text-lg opacity-90">{{ projectpost.title }}</p>
</section>
<label>Category:</label>
<input type="text" name="category" value="{{ projectpost.category }}">
<!-- Form -->
<section class="py-16 bg-black text-white">
<div class="max-w-3xl mx-auto px-6">
<form method="POST" enctype="multipart/form-data" class="space-y-6">
<label>Tags:</label>
<input type="text" name="tags" value="{{ projectpost.tags }}">
<!-- Title -->
<div>
<label for="title" class="block font-semibold mb-1">Post Title</label>
<input type="text" id="title" name="title" value="{{ projectpost.title }}" required
class="text-black bg-white w-full border border-gray-300 rounded px-4 py-2 focus:ring-2 focus:ring-orange-400 focus:outline-none">
</div>
<!-- Content -->
<div>
<label for="content" class="block font-semibold mb-1">Post Content</label>
<textarea id="content" name="content" required
class="text-black bg-white w-full border border-gray-300 rounded px-4 py-2 leading-relaxed text-base focus:ring-2 focus:ring-orange-400 focus:outline-none resize-none overflow-hidden"
rows="1">{{ projectpost.content|replace('<br>', '\n') }}</textarea>
</div>
<label>Upload New Images:</label>
<input type="file" name="images" multiple>
<!-- Category -->
<div>
<label for="category" class="block font-semibold mb-1">Category</label>
<input type="text" id="category" name="category" value="{{ projectpost.category or '' }}"
placeholder="What's This Post About?"
class="text-black bg-white w-full border border-gray-300 rounded px-4 py-2 focus:ring-2 focus:ring-orange-400 focus:outline-none">
</div>
<button type="submit">Save Changes</button>
<!-- Tags -->
<div>
<label for="tags" class="block font-semibold mb-1">Tags (comma-separated)</label>
<input type="text" id="tags" name="tags" value="{{ projectpost.tags or '' }}"
placeholder="e.g., Flask, SQLite, API"
class="text-black bg-white w-full border border-gray-300 rounded px-4 py-2 focus:ring-2 focus:ring-orange-400 focus:outline-none">
</div>
<!-- Pinned -->
<div class="flex items-center gap-2">
<input type="checkbox" id="pinned" name="pinned" {% if projectpost.pinned %}checked{% endif %}
class="w-4 h-4 accent-orange-500">
<label for="pinned" class="font-semibold">Pin this post</label>
</div>
<!-- Existing Images -->
{% if projectpost.images %}
{% set imgs = projectpost.images|from_json %}
{% if imgs %}
<div>
<p class="font-semibold mb-2">Current Images</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
{% for img in imgs %}
<div class="overflow-hidden rounded-lg border border-gray-700">
<img src="{{ url_for('static', filename='uploads/' + img) }}"
alt="{{ img }}" class="w-full h-32 object-cover">
<p class="text-xs text-gray-400 truncate px-2 py-1">{{ img }}</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
<!-- Upload New Images -->
<div>
<label for="images" class="block font-semibold mb-1">Upload New Images</label>
<input type="file" name="images" id="images" multiple
class="text-black w-full border border-gray-300 rounded px-3 py-2 text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:bg-orange-600 file:text-white hover:file:bg-orange-700">
<p class="text-xs text-gray-400 mt-1">New images are added alongside existing ones.</p>
</div>
<!-- Actions -->
<div class="pt-4 flex flex-wrap gap-3">
<button type="submit"
class="bg-orange-600 text-white px-6 py-2 rounded hover:bg-orange-700 transition">
Save Changes
</button>
<a href="{{ url_for('view_project', slug=projectpost.slug) }}"
class="bg-gray-700 text-white px-6 py-2 rounded hover:bg-gray-600 transition">
Cancel
</a>
</div>
</form>
</div>
</section>
<script src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
<script>
new EasyMDE({
element: document.getElementById('content'),
spellChecker: false,
toolbar: ['bold','italic','heading','|','quote','unordered-list','ordered-list','|','link','image','|','preview','side-by-side','fullscreen','|','guide'],
});
</script>
{% endblock %}

View File

@@ -24,7 +24,7 @@
</div>
<div class="justify-self-center">
<img src="{{ url_for('static', filename='Headshot.jpg') }}"
<img src="{{ url_for('static', filename='Headshot.jpeg') }}"
alt="Portrait of Benjamin Mosley"
class="h-40 w-40 sm:h-52 sm:w-52 rounded-2xl border border-[rgb(var(--border))] shadow-glass object-cover">
</div>

View File

@@ -2,6 +2,10 @@
{% block title %}New Blog Post{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
{% endblock %}
{% block content %}
<!-- Hero -->
@@ -12,21 +16,21 @@
<!-- Form -->
<section class="py-16 bg-black text-white">
<div class="max-w-3xl mx-auto px-6 max-h-[85vh] overflow-y-auto">
<div class="max-w-3xl mx-auto px-6">
<form action="{{ url_for('new_blog') }}" method="post" enctype="multipart/form-data" class="space-y-6">
<!-- Title -->
<div>
<label for="title" class="block font-semibold mb-1">Post Title</label>
<input type="text" id="title" name="title" required
class="text-black w-full border border-gray-300 rounded px-4 py-2 focus:ring-2 focus:ring-orange-400 focus:outline-none">
class="text-black bg-white w-full border border-gray-300 rounded px-4 py-2 focus:ring-2 focus:ring-orange-400 focus:outline-none">
</div>
<!-- Content -->
<div>
<label for="content" class="block font-semibold mb-1">Post Content</label>
<textarea id="content" name="content" required
class="text-black w-full border border-gray-300 rounded px-4 py-2 leading-relaxed text-base focus:ring-2 focus:ring-orange-400 focus:outline-none resize-none overflow-hidden"
class="text-black bg-white w-full border border-gray-300 rounded px-4 py-2 leading-relaxed text-base focus:ring-2 focus:ring-orange-400 focus:outline-none resize-none overflow-hidden"
rows="1"></textarea>
</div>
@@ -34,14 +38,14 @@
<div>
<label for="category" class="block font-semibold mb-1">Category</label>
<input type="text" id="category" name="category" placeholder="What's This Post About?"
class="text-black w-full border border-gray-300 rounded px-4 py-2 focus:ring-2 focus:ring-orange-400 focus:outline-none">
class="text-black bg-white w-full border border-gray-300 rounded px-4 py-2 focus:ring-2 focus:ring-orange-400 focus:outline-none">
</div>
<!-- Tags -->
<div>
<label for="tags" class="block font-semibold mb-1">Tags (comma-separated)</label>
<input type="text" id="tags" name="tags" placeholder="e.g., Flask, SQLite, API"
class="text-black w-full border border-gray-300 rounded px-4 py-2 focus:ring-2 focus:ring-orange-400 focus:outline-none">
class="text-black bg-white w-full border border-gray-300 rounded px-4 py-2 focus:ring-2 focus:ring-orange-400 focus:outline-none">
</div>
<!-- Images -->
@@ -52,10 +56,14 @@
</div>
<!-- Submit -->
<div class="pt-4">
<button type="submit"
<div class="pt-4 flex gap-3">
<button type="submit" name="action" value="draft"
class="bg-gray-600 text-white px-6 py-2 rounded hover:bg-gray-500 transition">
Save as Draft
</button>
<button type="submit" name="action" value="publish"
class="bg-orange-600 text-white px-6 py-2 rounded hover:bg-orange-700 transition">
Submit Post
Publish Post
</button>
</div>
</form>
@@ -67,18 +75,13 @@
</div>
</section>
<!-- Textarea autoresize -->
<script src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const textarea = document.getElementById('content');
if (textarea) {
const resize = () => {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
};
resize(); // Initial sizing
textarea.addEventListener('input', resize);
}
new EasyMDE({
element: document.getElementById('content'),
spellChecker: false,
autosave: { enabled: true, uniqueId: 'new_blog' },
toolbar: ['bold','italic','heading','|','quote','unordered-list','ordered-list','|','link','image','|','preview','side-by-side','fullscreen','|','guide'],
});
</script>

View File

@@ -2,6 +2,10 @@
{% block title %}New Project Post{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
{% endblock %}
{% block content %}
<!-- Hero Section -->
@@ -12,7 +16,7 @@
<!-- Form Section -->
<section class="py-16 bg-black text-white">
<div class="max-w-3xl mx-auto px-6 max-h-[85vh] overflow-y-auto">
<div class="max-w-3xl mx-auto px-6">
<form action="{{ url_for('new_project') }}" method="post" enctype="multipart/form-data" class="space-y-6">
<!-- Title -->
@@ -52,10 +56,14 @@
</div>
<!-- Submit -->
<div class="pt-4">
<button type="submit"
<div class="pt-4 flex gap-3">
<button type="submit" name="action" value="draft"
class="bg-gray-600 text-white px-6 py-2 rounded hover:bg-gray-500 transition">
Save as Draft
</button>
<button type="submit" name="action" value="publish"
class="bg-orange-600 text-white px-6 py-2 rounded hover:bg-orange-700 transition">
Submit Post
Publish Post
</button>
</div>
</form>
@@ -66,18 +74,13 @@
</div>
</section>
<!-- Textarea Auto-Expand Script -->
<script src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const textarea = document.getElementById('content');
if (textarea) {
const resize = () => {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
};
resize(); // Set initial height
textarea.addEventListener('input', resize);
}
new EasyMDE({
element: document.getElementById('content'),
spellChecker: false,
autosave: { enabled: true, uniqueId: 'new_project' },
toolbar: ['bold','italic','heading','|','quote','unordered-list','ordered-list','|','link','image','|','preview','side-by-side','fullscreen','|','guide'],
});
</script>

View File

@@ -7,31 +7,30 @@
<section class="relative text-center py-24 text-white bg-gradient-to-br from-orange-500/20 via-rose-500/10 to-fuchsia-500/10 backdrop-blur border-b border-white/10">
<div class="max-w-3xl mx-auto px-6">
<h1 class="text-5xl md:text-6xl font-archivo font-extrabold">Benjamin Mosley</h1>
<p class="mt-3 text-white/80 text-lg">E-Commerce Manager - CIS Student</p>
<p class="mt-3 text-white/80 text-lg">IT Support · Network &amp; Systems · CIS Student</p>
<div class="mt-6 flex flex-wrap items-center justify-center gap-3 text-sm">
<span class="px-3 py-1 rounded-full border border-white/15 bg-white/5">Canyon, TX 79015</span>
<span class="px-3 py-1 rounded-full border border-white/15 bg-white/5">Amarillo, TX 79119</span>
<a href="mailto:benjaymos@proton.me" class="px-3 py-1 rounded-full border border-white/15 bg-white/5 hover:bg-white/10 transition">benjaymos@proton.me</a>
<a href="https://benjaminmosley.com" target="_blank" class="px-3 py-1 rounded-full border border-white/15 bg-white/5 hover:bg-white/10 transition">benjaminmosley.com</a>
{# Optional: add a real static path if you place the PDF under /static/ #}
{# <a href="{{ url_for('static', filename='Benjamin Mosley - Resume.pdf') }}" class="btn btn-ghost">Download PDF</a> #}
</div>
</div>
</section>
<!-- About -->
<section class="py-14">
<!-- Leadership & Awards -->
<section class="py-10">
<div class="container-page">
<article class="card max-w-4xl mx-auto">
<h2 class="text-2xl font-semibold">About Me</h2>
<p class="mt-3 text-white/80 leading-relaxed">
Ive been working with computers for as long as I can remember—what started as curiosity has grown into a passion.
From backend scripting and networking to frontend design, I enjoy creating efficient and user-friendly solutions.
</p>
<h2 class="text-2xl font-semibold">Leadership &amp; Awards</h2>
<ul class="mt-3 text-white/80 space-y-2 list-disc pl-5">
<li>Carey Lyles Emerging Technology Award — West Texas A&amp;M University (2026)</li>
<li>Eagle Scout (Dec 2021)</li>
<li>National Youth Leadership Training (20172019)</li>
</ul>
</article>
</div>
</section>
<!-- Skills & Education (balanced two-column layout) -->
<!-- Skills & Education -->
<section class="py-6">
<div class="container-page grid md:grid-cols-2 gap-6 items-stretch">
<!-- Skills -->
@@ -40,40 +39,52 @@
<div class="mt-4 grid sm:grid-cols-2 gap-6 text-sm">
<div>
<p class="font-medium text-white/90">Programming</p>
<p class="font-medium text-white/90">Endpoint &amp; Systems</p>
<ul class="mt-2 space-y-1 text-white/70 list-disc pl-5">
<li>PC imaging &amp; deployment</li>
<li>Windows Server / AD</li>
<li>Microsoft 365 / Entra ID</li>
<li>Endpoint patching</li>
<li>Asset tracking</li>
</ul>
</div>
<div>
<p class="font-medium text-white/90">Networking</p>
<ul class="mt-2 space-y-1 text-white/70 list-disc pl-5">
<li>Switch &amp; AP config</li>
<li>Firewall rules</li>
<li>Fixed wireless (Cambium)</li>
<li>Fiber / Adtran</li>
<li>TCP/IP, DNS, DHCP</li>
</ul>
</div>
<div>
<p class="font-medium text-white/90">Scripting &amp; Dev</p>
<ul class="mt-2 space-y-1 text-white/70 list-disc pl-5">
<li>PowerShell / Bash</li>
<li>Python</li>
<li>C#, ASP.NET Core</li>
</ul>
</div>
<div>
<p class="font-medium text-white/90">Web &amp; Database</p>
<ul class="mt-2 space-y-1 text-white/70 list-disc pl-5">
<li>Flask, Tailwind CSS</li>
<li>SQL (MySQL, SQLite), EF Core</li>
</ul>
</div>
<div>
<p class="font-medium text-white/90">Systems</p>
<ul class="mt-2 space-y-1 text-white/70 list-disc pl-5">
<li>Linux, Windows Server</li>
<li>Networking &amp; Security</li>
<li>C# / ASP.NET</li>
<li>Flask / SQL</li>
<li>Git / GitHub</li>
</ul>
</div>
<div>
<p class="font-medium text-white/90">Professional</p>
<ul class="mt-2 space-y-1 text-white/70 list-disc pl-5">
<li>Leadership &amp; Team Ops</li>
<li>Process Improvement</li>
<li>Ticketing &amp; documentation</li>
<li>Root cause analysis</li>
<li>Vendor coordination</li>
<li>Team leadership</li>
<li>Process improvement</li>
</ul>
</div>
</div>
</article>
<!-- Education + Accomplishments -->
<!-- Education -->
<article class="card h-full">
<h2 class="text-2xl font-semibold">Education</h2>
<div class="mt-4">
@@ -81,14 +92,6 @@
<p class="text-white/70">BBA in Computer Information Systems</p>
<p class="text-white/60 text-sm mt-1">Expected May 2026</p>
</div>
<div class="mt-8 border-t border-[rgb(var(--border))] pt-6">
<h3 class="text-lg font-semibold">Accomplishments</h3>
<ul class="mt-3 text-white/80 space-y-2 list-disc pl-5">
<li>Eagle Scout (Dec 2021)</li>
<li>National Youth Leadership Training (20172019)</li>
</ul>
</div>
</article>
</div>
</section>
@@ -99,63 +102,79 @@
<article class="card">
<h2 class="text-2xl font-semibold">Experience</h2>
<!-- Current role -->
<!-- AMA TechTel -->
<div class="mt-6 relative pl-6">
<div class="absolute left-0 top-2 h-2 w-2 rounded-full bg-[rgb(var(--accent))]"></div>
<div class="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-1">
<p class="font-medium">E-Commerce Manager — United Supermarkets #532</p>
<p class="text-white/60 text-sm">Aug 2025 Present</p>
<p class="font-medium">Level II Technical Support · AMA TechTel</p>
<p class="text-white/60 text-sm">Mar 2026 Present</p>
</div>
<p class="text-white/80">Canyon, TX</p>
<p class="text-white/80">Amarillo, TX</p>
<ul class="mt-2 text-white/70 space-y-1 list-disc pl-5">
<li>Own day-to-day online grocery operations (order flow, substitutions, delivery/pickup SLA).</li>
<li>Lead and schedule the e-commerce team; coach on accuracy, speed, and CX.</li>
<li>Monitor inventory/pricing anomalies and coordinate fixes with department leads.</li>
<li>Track KPIs (fill rate, OTIF, cancels, customer feedback) and drive process improvements.</li>
<li>Partner with IT/vendors to keep handhelds, printers, and store systems humming.</li>
<li>Provide L2 support for a CLEC network managing Cambium fixed wireless and Adtran fiber equipment; diagnose and resolve connectivity, hardware, and provisioning issues via inbound phone support.</li>
<li>Document cases, resolutions, and escalation paths in Rev.io ticketing system with consistent root-cause and resolution detail.</li>
<li>Coordinate escalations with fiber resellers and dispatch/on-site technicians for issues requiring physical remediation.</li>
</ul>
</div>
<!-- Internship (closed out) -->
<!-- Hutchinson County -->
<div class="mt-8 relative pl-6">
<div class="absolute left-0 top-2 h-2 w-2 rounded-full bg-white/40"></div>
<div class="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-1">
<p class="font-medium">IT Intern / Junior System Administrator Hutchinson County Court House</p>
<p class="font-medium">IT Intern / Junior System Administrator · Hutchinson County Court House</p>
<p class="text-white/60 text-sm">May 2025 Aug 2025</p>
</div>
<p class="text-white/80">Stinnett, TX</p>
<ul class="mt-2 text-white/70 space-y-1 list-disc pl-5">
<li>Assisted with Windows Server/Active Directory admin and routine maintenance.</li>
<li>Resolved helpdesk tickets; imaged PCs and handled hardware/software issues.</li>
<li>Helped configure switches/APs/firewall rules; supported backups and updates.</li>
<li>Contributed to security policy rollouts and small infrastructure upgrades.</li>
<li>Assisted with Windows Server and Active Directory administration — user accounts, GPO, and routine maintenance.</li>
<li>Resolved helpdesk tickets; imaged and deployed PCs; handled hardware and software troubleshooting.</li>
<li>Configured managed switches, wireless APs, and firewall rules in a county government (regulated) environment; supported patching, backups, and security policy rollouts.</li>
</ul>
</div>
<!-- Earlier roles (optional condensed) -->
<!-- United Supermarkets -->
<div class="mt-8 relative pl-6">
<div class="absolute left-0 top-2 h-2 w-2 rounded-full bg-white/20"></div>
<div class="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-1">
<p class="font-medium">United Supermarkets &amp; The Pergola Shop — Various Roles</p>
<p class="text-white/60 text-sm">2019 2025</p>
<p class="font-medium">E-Commerce Dept. Manager &amp; Various Roles · United Supermarkets</p>
<p class="text-white/60 text-sm">Nov 2019 Mar 2026</p>
</div>
<p class="text-white/80">Canyon &amp; Borger, TX</p>
<ul class="mt-2 text-white/70 space-y-1 list-disc pl-5">
<li>Bookkeeper, Lead Stocker, Wood Cutter — leadership, reliability, and strong customer service.</li>
<li>Advanced from Sacker (2019) → Grocery Team Lead (2022), then Service Desk Clerk/Bookkeeper during college.</li>
<li>Promoted to E-Commerce Department Manager (Aug 2025 Mar 2026); owned online order flow, delivery/pickup SLA, team scheduling, and KPI tracking (fill rate, OTIF, cancellations).</li>
<li>Partnered with IT and vendors to maintain handheld devices, printers, and store systems; first point of contact for technology issues on the floor.</li>
</ul>
</div>
</article>
</div>
</section>
<!-- Projects & Homelab -->
<section class="pb-16">
<div class="container-page">
<article class="card max-w-4xl">
<h2 class="text-2xl font-semibold">Accomplishments</h2>
<ul class="mt-3 text-white/80 space-y-2 list-disc pl-5">
<li>Eagle Scout (Dec 2021)</li>
<li>National Youth Leadership Training (20172019)</li>
<article class="card">
<h2 class="text-2xl font-semibold">Projects &amp; Homelab</h2>
<!-- Proxmox Homelab -->
<div class="mt-6">
<p class="font-medium">Proxmox Homelab — Windows Server / Active Directory &amp; Linux</p>
<ul class="mt-2 text-white/70 space-y-1 list-disc pl-5">
<li>Self-managed Proxmox host running a Windows Server VM with AD DS and a Linux NAS providing Samba, Gitea, and Syncthing; configured Entra ID Connect to sync on-prem AD to a Microsoft 365 developer tenant.</li>
<li>Manages VM provisioning, snapshots, network segmentation, patching, and a self-hosted credential vault with client-side encryption (security-first design practice).</li>
</ul>
</div>
<!-- BillFlow -->
<div class="mt-6">
<div class="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-1">
<p class="font-medium">BillFlow — Self-Hosted SaaS Invoicing Platform</p>
<a href="https://netdeploy.net" target="_blank" rel="noopener" class="text-[rgb(var(--accent))] text-sm hover:underline">netdeploy.net</a>
</div>
<ul class="mt-2 text-white/70 space-y-1 list-disc pl-5">
<li>Built and deployed a multi-tenant invoicing platform (Flask/SQLite, nginx/Gunicorn, Vultr VPS); managed full deployment lifecycle including TLS, reverse proxy config, and ongoing maintenance.</li>
</ul>
</div>
</article>
</div>
</section>

View File

@@ -3,6 +3,22 @@
{% block content %}
{% if blogpost.draft %}
<div class="bg-yellow-500/20 border-b border-yellow-400/40 text-yellow-300 px-6 py-3 flex items-center justify-between gap-4">
<span class="font-semibold">&#9888; This post is a draft — only you can see it.</span>
<div class="flex gap-3">
<a href="{{ url_for('edit_blog', slug=blogpost.slug) }}"
class="bg-white/10 hover:bg-white/20 text-white text-sm px-4 py-1.5 rounded transition">Edit</a>
<form action="{{ url_for('publish_blog', slug=blogpost.slug) }}" method="post">
<button type="submit"
class="bg-orange-500 hover:bg-orange-600 text-white text-sm px-4 py-1.5 rounded transition">
Publish Post
</button>
</form>
</div>
</div>
{% endif %}
<!-- Hero -->
<section class="py-20 text-center text-white bg-gradient-to-br from-orange-500/20 via-rose-500/10 to-fuchsia-500/10 backdrop-blur border-b border-white/10">
<div class="max-w-3xl mx-auto px-6">
@@ -50,7 +66,7 @@
<!-- Content -->
<article class="prose prose-invert max-w-none leading-relaxed">
{{ blogpost.content | safe }}
{{ blogpost.content | markdown | safe }}
</article>
<div class="pt-2">

View File

@@ -3,6 +3,22 @@
{% block content %}
{% if projectpost.draft %}
<div class="bg-yellow-500/20 border-b border-yellow-400/40 text-yellow-300 px-6 py-3 flex items-center justify-between gap-4">
<span class="font-semibold">&#9888; This post is a draft — only you can see it.</span>
<div class="flex gap-3">
<a href="{{ url_for('edit_project', slug=projectpost.slug) }}"
class="bg-white/10 hover:bg-white/20 text-white text-sm px-4 py-1.5 rounded transition">Edit</a>
<form action="{{ url_for('publish_project', slug=projectpost.slug) }}" method="post">
<button type="submit"
class="bg-orange-500 hover:bg-orange-600 text-white text-sm px-4 py-1.5 rounded transition">
Publish Post
</button>
</form>
</div>
</div>
{% endif %}
<!-- Hero Section -->
<section class="py-20 text-center text-white bg-gradient-to-br from-orange-500/20 via-rose-500/10 to-fuchsia-500/10 backdrop-blur border-b border-white/10">
<div class="max-w-3xl mx-auto px-6">
@@ -48,7 +64,7 @@
<!-- Content -->
<article class="prose prose-invert max-w-none leading-relaxed">
{{ projectpost.content | safe }}
{{ projectpost.content | markdown | safe }}
</article>
<div class="pt-2">