QoL update
This commit is contained in:
222
app.py
222
app.py
@@ -2,27 +2,21 @@ from flask import Flask, request, redirect, url_for, render_template, session, f
|
|||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from sqlalchemy import func
|
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"),
|
||||||
@@ -31,7 +25,7 @@ CONTACT = {
|
|||||||
"phone": os.environ.get("BH_CONTACT_PHONE", "(806) 655 2300"),
|
"phone": os.environ.get("BH_CONTACT_PHONE", "(806) 655 2300"),
|
||||||
"city": os.environ.get("BH_CONTACT_CITY", "Canyon / Amarillo / Borger / Remote"),
|
"city": os.environ.get("BH_CONTACT_CITY", "Canyon / Amarillo / Borger / Remote"),
|
||||||
"cal": os.environ.get("BH_CONTACT_CAL", "https://calendly.com/bennyshouse24/30min"),
|
"cal": os.environ.get("BH_CONTACT_CAL", "https://calendly.com/bennyshouse24/30min"),
|
||||||
"link": os.environ.get("BH_CONTACT_LINK", "https://www.linkedin.com/in/benjamin-mosley-849643329/"),
|
"link": os.environ.get("BH_CONTACT_LINK", "https://www.linkedin.com/in/benjamin-mosley-849643329/"),
|
||||||
"site": os.environ.get("BH_CONTACT_SITE", "https://bennyshouse.net"),
|
"site": os.environ.get("BH_CONTACT_SITE", "https://bennyshouse.net"),
|
||||||
"hours": os.environ.get("BH_CONTACT_HOURS", "Mon–Fri, 9a–5p CT"),
|
"hours": os.environ.get("BH_CONTACT_HOURS", "Mon–Fri, 9a–5p CT"),
|
||||||
}
|
}
|
||||||
@@ -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):
|
||||||
@@ -71,23 +63,32 @@ class BlogPost(db.Model):
|
|||||||
title = db.Column(db.String(255), nullable=False)
|
title = db.Column(db.String(255), nullable=False)
|
||||||
slug = db.Column(db.String(255), nullable=False)
|
slug = db.Column(db.String(255), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
images = db.Column(db.Text, nullable=True)
|
images = db.Column(db.Text, nullable=True)
|
||||||
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)
|
||||||
title = db.Column(db.String(255), nullable=False)
|
title = db.Column(db.String(255), nullable=False)
|
||||||
slug = db.Column(db.String(255), nullable=False)
|
slug = db.Column(db.String(255), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
images = db.Column(db.Text, nullable=True)
|
images = db.Column(db.Text, nullable=True)
|
||||||
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.
@@ -5,3 +5,5 @@ flask-mail
|
|||||||
python-dotenv
|
python-dotenv
|
||||||
python-slugify
|
python-slugify
|
||||||
werkzeug
|
werkzeug
|
||||||
|
requests
|
||||||
|
markdown
|
||||||
BIN
static/Headshot.jpeg
Normal file
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.
@@ -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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
</form>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 -->
|
||||||
</form>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 & 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 & 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">
|
||||||
I’ve 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&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 (2017–2019)</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 & 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 & 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 & 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 & 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 & 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 & 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 & Team Ops</li>
|
<li>Ticketing & 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 (2017–2019)</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 & The Pergola Shop — Various Roles</p>
|
<p class="font-medium">E-Commerce Dept. Manager & 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 & 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 & 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 (2017–2019)</li>
|
<div class="mt-6">
|
||||||
</ul>
|
<p class="font-medium">Proxmox Homelab — Windows Server / Active Directory & 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>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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">⚠ 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">
|
||||||
|
|||||||
@@ -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">⚠ 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user