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 from dotenv import load_dotenv
import os import os
from werkzeug.utils import secure_filename 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 from flask_mail import Mail, Message
import json
from slugify import slugify from slugify import slugify
from datetime import datetime from datetime import datetime
import hmac, hashlib, json, time
from pathlib import Path 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")) load_dotenv(Path(__file__).with_name(".env"))
app = Flask(__name__, static_folder="static", static_url_path="/static") 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 = { CONTACT = {
"name": os.environ.get("BH_CONTACT_NAME", "Benjamin Mosley"), "name": os.environ.get("BH_CONTACT_NAME", "Benjamin Mosley"),
@@ -60,10 +54,8 @@ def load_user(user_id):
return User() return User()
return None return None
@app.context_processor @app.context_processor
def inject_now(): def inject_now():
# lets you use {{ now().year }} in any template
return {"now": datetime.now} return {"now": datetime.now}
class BlogPost(db.Model): class BlogPost(db.Model):
@@ -75,6 +67,7 @@ class BlogPost(db.Model):
category = db.Column(db.String(100), nullable=True) category = db.Column(db.String(100), nullable=True)
tags = db.Column(db.Text, nullable=True) tags = db.Column(db.Text, nullable=True)
pinned = db.Column(db.Boolean, default=False) pinned = db.Column(db.Boolean, default=False)
draft = db.Column(db.Boolean, default=False)
class ProjectPost(db.Model): class ProjectPost(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@@ -85,9 +78,17 @@ class ProjectPost(db.Model):
category = db.Column(db.String(100), nullable=True) category = db.Column(db.String(100), nullable=True)
tags = db.Column(db.Text, nullable=True) tags = db.Column(db.Text, nullable=True)
pinned = db.Column(db.Boolean, default=False) pinned = db.Column(db.Boolean, default=False)
draft = db.Column(db.Boolean, default=False)
with app.app_context(): with app.app_context():
db.create_all() 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') @app.template_filter('from_json')
def from_json(value): def from_json(value):
@@ -96,71 +97,31 @@ def from_json(value):
except (TypeError, json.JSONDecodeError): except (TypeError, json.JSONDecodeError):
return [] 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): 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') 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() sig = hmac.new(api_secret.encode('utf-8'), body, hashlib.sha256).hexdigest()
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Hub-Signature-256": f"sha256={sig}", "X-Hub-Signature-256": f"sha256={sig}",
# Optional if you later check timestamps:
# "X-Timestamp": str(int(time.time())),
} }
return requests.post(push_url, headers=headers, data=body, timeout=15)
# Send the *exact same* bytes you signed
r = requests.post(push_url, headers=headers, data=body, timeout=15)
return r
def _verify_hub_signature(raw: bytes) -> None: def _verify_hub_signature(raw: bytes) -> None:
if not HUB_PUSH_SECRET: if not HUB_PUSH_SECRET:
abort(403, description="Missing HUB_PUSH_SECRET") abort(403, description="Missing HUB_PUSH_SECRET")
# Accept both header names
sig_hdr = (request.headers.get("X-Signature") sig_hdr = (request.headers.get("X-Signature")
or request.headers.get("X-Hub-Signature-256") or request.headers.get("X-Hub-Signature-256")
or "").strip() or "").strip()
# Accept either "sha256=<hex>" or just "<hex>"
provided = sig_hdr provided = sig_hdr
if provided.lower().startswith("sha256="): if provided.lower().startswith("sha256="):
provided = provided.split("=", 1)[1].strip() 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: if not provided or any(c.isspace() for c in provided) or len(provided) != 64:
abort(403, description="Bad signature format") 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: if ts and ts.isdigit() and abs(time.time() - int(ts)) > 300:
abort(403, description="Stale request") abort(403, description="Stale request")
def _to_slug(text: str) -> str: def _to_slug(text: str) -> str:
s = slugify(text or "") s = slugify(text or "")
return s or f"post-{int(time.time())}" return s or f"post-{int(time.time())}"
@@ -188,74 +148,44 @@ def api_publish():
kind = (data.get("kind") or "blog").lower() kind = (data.get("kind") or "blog").lower()
title = data.get("title") or "Untitled" title = data.get("title") or "Untitled"
summary = data.get("summary")
content_html = data.get("content_html") or "" content_html = data.get("content_html") or ""
content_text = data.get("content_text") or "" content_text = data.get("content_text") or ""
tags = ",".join(data.get("tags") or []) tags = ",".join(data.get("tags") or [])
images = data.get("images") or [] images = data.get("images") or []
pinned = bool(data.get("pinned")) 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) slug = _to_slug(external_id or title)
# Normalize content to your existing schema
body = content_html or content_text.replace("\n", "<br>") body = content_html or content_text.replace("\n", "<br>")
images_json = json.dumps(images) if images else None images_json = json.dumps(images) if images else None
if kind == "project": if kind == "project":
obj = ProjectPost( obj = ProjectPost(
title=title, title=title, slug=slug, content=body,
slug=slug, category=None, tags=tags, pinned=pinned, images=images_json
content=body,
category=None,
tags=tags,
pinned=pinned,
images=images_json
) )
else: else:
obj = BlogPost( obj = BlogPost(
title=title, title=title, slug=slug, content=body,
slug=slug, category=None, tags=tags, pinned=pinned, images=images_json
content=body,
category=None,
tags=tags,
pinned=pinned,
images=images_json
) )
db.session.add(obj) db.session.add(obj)
db.session.commit() 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) 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}) 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"]) @app.route("/login", methods=["GET", "POST"])
def login(): def login():
if request.method == "POST": if request.method == "POST":
username = request.form["username"] username = request.form["username"]
password = request.form["password"] password = request.form["password"]
if username == USERNAME and password == PASSWORD: if username == USERNAME and password == PASSWORD:
user = User() user = User()
login_user(user) login_user(user)
session.permanent = True session.permanent = True
return redirect(url_for("admin_panel")) return redirect(url_for("admin_panel"))
flash("Invalid email or password!", "danger") flash("Invalid email or password!", "danger")
return render_template("login.html") return render_template("login.html")
@@ -265,7 +195,6 @@ def logout():
logout_user() logout_user()
return redirect(url_for("index")) return redirect(url_for("index"))
@app.route('/admin') @app.route('/admin')
@login_required @login_required
def admin_panel(): def admin_panel():
@@ -278,10 +207,11 @@ def admin_panel():
def new_blog(): def new_blog():
if request.method == 'POST': if request.method == 'POST':
title = request.form['title'] title = request.form['title']
content = request.form['content'].replace("\n", "<br>") content = request.form['content']
category = request.form['category'] category = request.form['category']
tags = request.form['tags'] tags = request.form['tags']
pinned = 'pinned' in request.form pinned = 'pinned' in request.form
is_draft = request.form.get('action') == 'draft'
images = request.files.getlist('images') images = request.files.getlist('images')
slug = slugify(title) slug = slugify(title)
@@ -290,17 +220,13 @@ def new_blog():
image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
new_post = BlogPost( new_post = BlogPost(
title=title, title=title, slug=slug, content=content,
slug=slug, category=category, tags=tags, pinned=pinned,
content=content, images=json.dumps(image_filenames), draft=is_draft
category=category,
tags=tags,
pinned=pinned,
images=json.dumps(image_filenames)
) )
db.session.add(new_post) db.session.add(new_post)
db.session.commit() db.session.commit()
return redirect(url_for('blog')) return redirect(url_for('view_blog', slug=slug))
return render_template('new_blog.html') return render_template('new_blog.html')
@@ -309,10 +235,11 @@ def new_blog():
def new_project(): def new_project():
if request.method == 'POST': if request.method == 'POST':
title = request.form['title'] title = request.form['title']
content = request.form['content'].replace("\n", "<br>") content = request.form['content']
category = request.form['category'] category = request.form['category']
tags = request.form['tags'] tags = request.form['tags']
pinned = 'pinned' in request.form pinned = 'pinned' in request.form
is_draft = request.form.get('action') == 'draft'
images = request.files.getlist('images') images = request.files.getlist('images')
slug = slugify(title) slug = slugify(title)
@@ -321,17 +248,13 @@ def new_project():
image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
new_post = ProjectPost( new_post = ProjectPost(
title=title, title=title, slug=slug, content=content,
slug=slug, category=category, tags=tags, pinned=pinned,
content=content, images=json.dumps(image_filenames), draft=is_draft
category=category,
tags=tags,
pinned=pinned,
images=json.dumps(image_filenames)
) )
db.session.add(new_post) db.session.add(new_post)
db.session.commit() db.session.commit()
return redirect(url_for('projects')) return redirect(url_for('view_project', slug=slug))
return render_template('new_project.html') return render_template('new_project.html')
@@ -339,18 +262,16 @@ def new_project():
@login_required @login_required
def edit_blog(slug): def edit_blog(slug):
blogpost = BlogPost.query.filter_by(slug=slug).first_or_404() blogpost = BlogPost.query.filter_by(slug=slug).first_or_404()
if request.method == 'POST': if request.method == 'POST':
blogpost.title = request.form['title'] blogpost.title = request.form['title']
blogpost.slug = slugify(blogpost.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.category = request.form['category']
blogpost.tags = request.form['tags'] blogpost.tags = request.form['tags']
blogpost.pinned = 'pinned' in request.form blogpost.pinned = 'pinned' in request.form
images = request.files.getlist('images') images = request.files.getlist('images')
image_filenames = json.loads(blogpost.images) if blogpost.images else [] image_filenames = json.loads(blogpost.images) if blogpost.images else []
for image in images: for image in images:
if image.filename: if image.filename:
filename = secure_filename(image.filename) filename = secure_filename(image.filename)
@@ -367,18 +288,16 @@ def edit_blog(slug):
@login_required @login_required
def edit_project(slug): def edit_project(slug):
projectpost = ProjectPost.query.filter_by(slug=slug).first_or_404() projectpost = ProjectPost.query.filter_by(slug=slug).first_or_404()
if request.method == 'POST': if request.method == 'POST':
projectpost.title = request.form['title'] projectpost.title = request.form['title']
projectpost.slug = slugify(projectpost.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.category = request.form['category']
projectpost.tags = request.form['tags'] projectpost.tags = request.form['tags']
projectpost.pinned = 'pinned' in request.form projectpost.pinned = 'pinned' in request.form
images = request.files.getlist('images') images = request.files.getlist('images')
image_filenames = json.loads(projectpost.images) if projectpost.images else [] image_filenames = json.loads(projectpost.images) if projectpost.images else []
for image in images: for image in images:
if image.filename: if image.filename:
filename = secure_filename(image.filename) filename = secure_filename(image.filename)
@@ -419,36 +338,56 @@ def delete_project(slug):
@app.route('/blogtag/<tag>') @app.route('/blogtag/<tag>')
def view_tag(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() 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) return render_template('blog_tag_results.html', blogpost=blogpost, tag=tag)
@app.route('/projecttag/<tag>') @app.route('/projecttag/<tag>')
def view_project_tag(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() 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) return render_template('project_tag_results.html', projectpost=projectpost, tag=tag)
@app.route('/blog') @app.route('/blog')
def 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) return render_template('blog.html', blogpost=blogpost)
@app.route('/projects') @app.route('/projects')
def 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) return render_template('project.html', projectpost=projectpost)
@app.route('/blog/<slug>') @app.route('/blog/<slug>')
def view_blog(slug): def view_blog(slug):
blogpost = BlogPost.query.filter_by(slug=slug).first_or_404() 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) return render_template('view_blog.html', blogpost=blogpost)
@app.route('/project/<slug>') @app.route('/project/<slug>')
def view_project(slug): def view_project(slug):
projectpost = ProjectPost.query.filter_by(slug=slug).first_or_404() 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) 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') @app.route('/contact')
def contact(): def contact():
return render_template('contact.html', CONTACT=CONTACT) return render_template('contact.html', CONTACT=CONTACT)
@@ -465,32 +404,5 @@ def about():
def index(): def index():
return render_template('index.html') return render_template('index.html')
# ---- patched helpers (appended) ---- if __name__ == '__main__':
def _verify_hub_signature(raw: bytes) -> None: app.run(debug=True)
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())}"

Binary file not shown.

View File

@@ -5,3 +5,5 @@ flask-mail
python-dotenv python-dotenv
python-slugify python-slugify
werkzeug 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> <tbody>
{% for projectpost in projectpost %} {% for projectpost in projectpost %}
<tr class="border-b border-[rgb(var(--border))] hover:bg-[rgb(var(--card))]/60 transition"> <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 text-[rgb(var(--muted))]">{{ projectpost.category }}</td>
<td class="px-5 py-3 flex flex-wrap gap-2"> <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> <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?');"> <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> <button type="submit" class="btn btn-ghost border-none bg-red-600 hover:bg-red-700 text-white">Delete</button>
</form> </form>
@@ -74,11 +84,21 @@
<tbody> <tbody>
{% for blogpost in blogpost %} {% for blogpost in blogpost %}
<tr class="border-b border-[rgb(var(--border))] hover:bg-[rgb(var(--card))]/60 transition"> <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 text-[rgb(var(--muted))]">{{ blogpost.category }}</td>
<td class="px-5 py-3 flex flex-wrap gap-2"> <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> <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?');"> <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> <button type="submit" class="btn btn-ghost border-none bg-red-600 hover:bg-red-700 text-white">Delete</button>
</form> </form>

View File

@@ -1,29 +1,111 @@
{% extends "base.html" %} {% 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 %} {% block content %}
<form method="POST" enctype="multipart/form-data"> <!-- Hero -->
<label>Title:</label> <section class="py-20 bg-gradient-to-br from-red-800 to-orange-400 text-white text-center">
<input type="text" name="title" value="{{ blogpost.title }}" required> <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> <!-- Form -->
<textarea name="content">{{ blogpost.content|replace('<br>', '\n') }}</textarea> <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> <!-- Title -->
<input type="text" name="category" value="{{ blogpost.category }}"> <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> <!-- Content -->
<input type="text" name="tags" value="{{ blogpost.tags }}"> <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> <!-- Tags -->
<input type="file" name="images" multiple> <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> </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 %} {% endblock %}

View File

@@ -1,26 +1,111 @@
{% extends "base.html" %} {% 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 %} {% block content %}
<form method="POST" enctype="multipart/form-data">
<label>Title:</label>
<input type="text" name="title" value="{{ projectpost.title }}" required>
<label>Content:</label> <!-- Hero -->
<textarea name="content">{{ projectpost.content|replace('<br>', '\n') }}</textarea> <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> <!-- Form -->
<input type="text" name="category" value="{{ projectpost.category }}"> <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> <!-- Title -->
<input type="text" name="tags" value="{{ projectpost.tags }}"> <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> <!-- Category -->
<input type="file" name="images" multiple> <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> </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 %} {% endblock %}

View File

@@ -24,7 +24,7 @@
</div> </div>
<div class="justify-self-center"> <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" 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"> class="h-40 w-40 sm:h-52 sm:w-52 rounded-2xl border border-[rgb(var(--border))] shadow-glass object-cover">
</div> </div>

View File

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

View File

@@ -2,6 +2,10 @@
{% block title %}New Project Post{% endblock %} {% block title %}New Project Post{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
{% endblock %}
{% block content %} {% block content %}
<!-- Hero Section --> <!-- Hero Section -->
@@ -12,7 +16,7 @@
<!-- Form Section --> <!-- Form Section -->
<section class="py-16 bg-black text-white"> <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"> <form action="{{ url_for('new_project') }}" method="post" enctype="multipart/form-data" class="space-y-6">
<!-- Title --> <!-- Title -->
@@ -52,10 +56,14 @@
</div> </div>
<!-- Submit --> <!-- Submit -->
<div class="pt-4"> <div class="pt-4 flex gap-3">
<button type="submit" <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"> class="bg-orange-600 text-white px-6 py-2 rounded hover:bg-orange-700 transition">
Submit Post Publish Post
</button> </button>
</div> </div>
</form> </form>
@@ -66,18 +74,13 @@
</div> </div>
</section> </section>
<!-- Textarea Auto-Expand Script --> <script src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { new EasyMDE({
const textarea = document.getElementById('content'); element: document.getElementById('content'),
if (textarea) { spellChecker: false,
const resize = () => { autosave: { enabled: true, uniqueId: 'new_project' },
textarea.style.height = 'auto'; toolbar: ['bold','italic','heading','|','quote','unordered-list','ordered-list','|','link','image','|','preview','side-by-side','fullscreen','|','guide'],
textarea.style.height = textarea.scrollHeight + 'px';
};
resize(); // Set initial height
textarea.addEventListener('input', resize);
}
}); });
</script> </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"> <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"> <div class="max-w-3xl mx-auto px-6">
<h1 class="text-5xl md:text-6xl font-archivo font-extrabold">Benjamin Mosley</h1> <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"> <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="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> <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>
</div> </div>
</section> </section>
<!-- About --> <!-- Leadership & Awards -->
<section class="py-14"> <section class="py-10">
<div class="container-page"> <div class="container-page">
<article class="card max-w-4xl mx-auto"> <article class="card max-w-4xl mx-auto">
<h2 class="text-2xl font-semibold">About Me</h2> <h2 class="text-2xl font-semibold">Leadership &amp; Awards</h2>
<p class="mt-3 text-white/80 leading-relaxed"> <ul class="mt-3 text-white/80 space-y-2 list-disc pl-5">
Ive been working with computers for as long as I can remember—what started as curiosity has grown into a passion. <li>Carey Lyles Emerging Technology Award — West Texas A&amp;M University (2026)</li>
From backend scripting and networking to frontend design, I enjoy creating efficient and user-friendly solutions. <li>Eagle Scout (Dec 2021)</li>
</p> <li>National Youth Leadership Training (20172019)</li>
</ul>
</article> </article>
</div> </div>
</section> </section>
<!-- Skills & Education (balanced two-column layout) --> <!-- Skills & Education -->
<section class="py-6"> <section class="py-6">
<div class="container-page grid md:grid-cols-2 gap-6 items-stretch"> <div class="container-page grid md:grid-cols-2 gap-6 items-stretch">
<!-- Skills --> <!-- Skills -->
@@ -40,40 +39,52 @@
<div class="mt-4 grid sm:grid-cols-2 gap-6 text-sm"> <div class="mt-4 grid sm:grid-cols-2 gap-6 text-sm">
<div> <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"> <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>Python</li>
<li>C#, ASP.NET Core</li> <li>C# / ASP.NET</li>
</ul> <li>Flask / SQL</li>
</div> <li>Git / GitHub</li>
<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>
</ul> </ul>
</div> </div>
<div> <div>
<p class="font-medium text-white/90">Professional</p> <p class="font-medium text-white/90">Professional</p>
<ul class="mt-2 space-y-1 text-white/70 list-disc pl-5"> <ul class="mt-2 space-y-1 text-white/70 list-disc pl-5">
<li>Leadership &amp; Team Ops</li> <li>Ticketing &amp; documentation</li>
<li>Process Improvement</li> <li>Root cause analysis</li>
<li>Vendor coordination</li>
<li>Team leadership</li>
<li>Process improvement</li>
</ul> </ul>
</div> </div>
</div> </div>
</article> </article>
<!-- Education + Accomplishments --> <!-- Education -->
<article class="card h-full"> <article class="card h-full">
<h2 class="text-2xl font-semibold">Education</h2> <h2 class="text-2xl font-semibold">Education</h2>
<div class="mt-4"> <div class="mt-4">
@@ -81,14 +92,6 @@
<p class="text-white/70">BBA in Computer Information Systems</p> <p class="text-white/70">BBA in Computer Information Systems</p>
<p class="text-white/60 text-sm mt-1">Expected May 2026</p> <p class="text-white/60 text-sm mt-1">Expected May 2026</p>
</div> </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> </article>
</div> </div>
</section> </section>
@@ -99,63 +102,79 @@
<article class="card"> <article class="card">
<h2 class="text-2xl font-semibold">Experience</h2> <h2 class="text-2xl font-semibold">Experience</h2>
<!-- Current role --> <!-- AMA TechTel -->
<div class="mt-6 relative pl-6"> <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="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"> <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="font-medium">Level II Technical Support · AMA TechTel</p>
<p class="text-white/60 text-sm">Aug 2025 Present</p> <p class="text-white/60 text-sm">Mar 2026 Present</p>
</div> </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"> <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>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>Lead and schedule the e-commerce team; coach on accuracy, speed, and CX.</li> <li>Document cases, resolutions, and escalation paths in Rev.io ticketing system with consistent root-cause and resolution detail.</li>
<li>Monitor inventory/pricing anomalies and coordinate fixes with department leads.</li> <li>Coordinate escalations with fiber resellers and dispatch/on-site technicians for issues requiring physical remediation.</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>
</ul> </ul>
</div> </div>
<!-- Internship (closed out) --> <!-- Hutchinson County -->
<div class="mt-8 relative pl-6"> <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="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"> <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> <p class="text-white/60 text-sm">May 2025 Aug 2025</p>
</div> </div>
<p class="text-white/80">Stinnett, TX</p> <p class="text-white/80">Stinnett, TX</p>
<ul class="mt-2 text-white/70 space-y-1 list-disc pl-5"> <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>Assisted with Windows Server and Active Directory administration — user accounts, GPO, and routine maintenance.</li>
<li>Resolved helpdesk tickets; imaged PCs and handled hardware/software issues.</li> <li>Resolved helpdesk tickets; imaged and deployed PCs; handled hardware and software troubleshooting.</li>
<li>Helped configure switches/APs/firewall rules; supported backups and updates.</li> <li>Configured managed switches, wireless APs, and firewall rules in a county government (regulated) environment; supported patching, backups, and security policy rollouts.</li>
<li>Contributed to security policy rollouts and small infrastructure upgrades.</li>
</ul> </ul>
</div> </div>
<!-- Earlier roles (optional condensed) --> <!-- United Supermarkets -->
<div class="mt-8 relative pl-6"> <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="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"> <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="font-medium">E-Commerce Dept. Manager &amp; Various Roles · United Supermarkets</p>
<p class="text-white/60 text-sm">2019 2025</p> <p class="text-white/60 text-sm">Nov 2019 Mar 2026</p>
</div> </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"> <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> </ul>
</div> </div>
</article> </article>
</div> </div>
</section> </section>
<!-- Projects & Homelab -->
<section class="pb-16"> <section class="pb-16">
<div class="container-page"> <div class="container-page">
<article class="card max-w-4xl"> <article class="card">
<h2 class="text-2xl font-semibold">Accomplishments</h2> <h2 class="text-2xl font-semibold">Projects &amp; Homelab</h2>
<ul class="mt-3 text-white/80 space-y-2 list-disc pl-5">
<li>Eagle Scout (Dec 2021)</li> <!-- Proxmox Homelab -->
<li>National Youth Leadership Training (20172019)</li> <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> </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> </article>
</div> </div>
</section> </section>

View File

@@ -3,6 +3,22 @@
{% block content %} {% 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 --> <!-- 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"> <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"> <div class="max-w-3xl mx-auto px-6">
@@ -50,7 +66,7 @@
<!-- Content --> <!-- Content -->
<article class="prose prose-invert max-w-none leading-relaxed"> <article class="prose prose-invert max-w-none leading-relaxed">
{{ blogpost.content | safe }} {{ blogpost.content | markdown | safe }}
</article> </article>
<div class="pt-2"> <div class="pt-2">

View File

@@ -3,6 +3,22 @@
{% block content %} {% 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 --> <!-- 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"> <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"> <div class="max-w-3xl mx-auto px-6">
@@ -48,7 +64,7 @@
<!-- Content --> <!-- Content -->
<article class="prose prose-invert max-w-none leading-relaxed"> <article class="prose prose-invert max-w-none leading-relaxed">
{{ projectpost.content | safe }} {{ projectpost.content | markdown | safe }}
</article> </article>
<div class="pt-2"> <div class="pt-2">