2 Commits

Author SHA1 Message Date
74019a967a feat: Add initial ascii art for curl connections
All checks were successful
Build Docker / BuildImage (push) Successful in 49s
2025-10-26 18:03:23 +11:00
8f774ba8f0 feat: Added tools page
All checks were successful
Build Docker / BuildImage (push) Successful in 2m9s
2025-10-26 18:00:18 +11:00
18 changed files with 604 additions and 13 deletions

View File

@@ -10,6 +10,10 @@ blog_bp = Blueprint('blog', __name__)
def list_page_files():
blog_pages = os.listdir("data/blog")
# Sort pages by modified time, newest first
blog_pages.sort(
key=lambda x: os.path.getmtime(os.path.join("data/blog", x)), reverse=True)
# Remove .md extension
blog_pages = [page.removesuffix(".md")
for page in blog_pages if page.endswith(".md")]

9
blueprints/template.py Normal file
View File

@@ -0,0 +1,9 @@
from flask import Blueprint, request
from tools import json_response
template_bp = Blueprint('template', __name__)
@template_bp.route("/")
def index():
return json_response(request, "Success", 200)

121
curl.py Normal file
View File

@@ -0,0 +1,121 @@
from flask import render_template
from tools import error_response, getAddress
import os
from functools import lru_cache
import requests
def clean_path(path:str):
path = path.strip("/ ").lower()
# Strip any .html extension
if path.endswith(".html"):
path = path[:-5]
# If the path is empty, set it to "index"
if path == "":
path = "index"
return path
@lru_cache(maxsize=1)
def get_header():
with open("templates/header.ascii", "r") as f:
return f.read()
@lru_cache(maxsize=1)
def get_current_project():
git = requests.get(
"https://git.woodburn.au/api/v1/users/nathanwoodburn/activities/feeds?only-performed-by=true&limit=1",
headers={"Authorization": os.getenv("GIT_AUTH") if os.getenv("GIT_AUTH") else os.getenv("git_token")},
)
git = git.json()
git = git[0]
repo_name = git["repo"]["name"]
repo_name = repo_name.lower()
repo_description = git["repo"]["description"]
return f"{repo_name} - {repo_description}"
@lru_cache(maxsize=1)
def get_projects():
projectsreq = requests.get(
"https://git.woodburn.au/api/v1/users/nathanwoodburn/repos"
)
projects = projectsreq.json()
# Check for next page
pageNum = 1
while 'rel="next"' in projectsreq.headers["link"]:
projectsreq = requests.get(
"https://git.woodburn.au/api/v1/users/nathanwoodburn/repos?page="
+ str(pageNum)
)
projects += projectsreq.json()
pageNum += 1
# Sort by last updated
projectsList = sorted(
projects, key=lambda x: x["updated_at"], reverse=True)
projects = ""
projectNum = 0
includedNames = []
while len(includedNames) < 5 and projectNum < len(projectsList):
# Avoid duplicates
if projectsList[projectNum]["name"] in includedNames:
projectNum += 1
continue
includedNames.append(projectsList[projectNum]["name"])
project = projectsList[projectNum]
projects += f"""{project['name']} - {project['description'] if project['description'] else 'No description'}
{project['html_url']}
"""
projectNum += 1
return projects
def curl_response(request):
# Check if <path>.ascii exists
path = clean_path(request.path)
# Handle special cases
if path == "index":
# Get current project
return render_template("index.ascii",header=get_header(),repo=get_current_project()), 200, {'Content-Type': 'text/plain; charset=utf-8'}
if path == "projects":
# Get projects
return render_template("projects.ascii",header=get_header(),projects=get_projects()), 200, {'Content-Type': 'text/plain; charset=utf-8'}
if path == "donate":
# Get donation info
return render_template("donate.ascii",header=get_header(),
HNS=getAddress("HNS"), BTC=getAddress("BTC"),
SOL=getAddress("SOL"), ETH=getAddress("ETH")
), 200, {'Content-Type': 'text/plain; charset=utf-8'}
if path == "donate/more":
coinList = os.listdir(".well-known/wallets")
coinList = [file for file in coinList if file[0] != "."]
coinList.sort()
return render_template("donate_more.ascii",header=get_header(),
coins=coinList
), 200, {'Content-Type': 'text/plain; charset=utf-8'}
# For other donation pages, fall back to ascii if it exists
if path.startswith("donate/"):
coin = path.split("/")[1]
address = getAddress(coin)
if address != "":
return render_template("donate_coin.ascii",header=get_header(),coin=coin.upper(),address=address), 200, {'Content-Type': 'text/plain; charset=utf-8'}
if os.path.exists(f"templates/{path}.ascii"):
return render_template(f"{path}.ascii",header=get_header()), 200, {'Content-Type': 'text/plain; charset=utf-8'}
# Fallback to html if it exists
if os.path.exists(f"templates/{path}.html"):
return render_template(f"{path}.html")
return error_response(request)

View File

@@ -1,7 +1,9 @@
G'day,
Just thought it might be useful to write down some of the software I use regularly. I've no clue if you'll find any useful :)
For a more complete list, check out [/tools](/tools)
<br>
## Overview
OS: Arch Linux | Because it is quick to update and has all the latest tools I can play with
DE: Hyprland | Feel free to check out my dotfiles if you're interested

164
data/tools.json Normal file
View File

@@ -0,0 +1,164 @@
[
{
"name":"Obsidian",
"type":"Desktop Applications",
"url":"https://obsidian.md/",
"description":"Note taking app that stores everything in Markdown files"
},
{
"name": "Alacritty",
"type": "Desktop Applications",
"url": "https://alacritty.org/",
"description": "A cross-platform, GPU-accelerated terminal emulator"
},
{
"name": "Brave",
"type": "Desktop Applications",
"url": "https://brave.com/",
"description": "Privacy-focused web browser"
},
{
"name": "VSCode",
"type": "Desktop Applications",
"url": "https://code.visualstudio.com/",
"description": "Source-code editor developed by Microsoft"
},
{
"name": "Zellij",
"type": "terminal",
"url": "https://zellij.dev/",
"description": "A terminal workspace and multiplexer"
},
{
"name": "Fx",
"type": "terminal",
"url": "https://fx.wtf/",
"description": "A command-line JSON viewer and processor",
"demo": "<script src=\"https://asciinema.c.woodburn.au/a/cx8sr8adsP6Uoi9zpg8MYnVTl.js\" id=\"asciicast-cx8sr8adsP6Uoi9zpg8MYnVTl\" async=\"true\"></script>"
},
{
"name": "Zoxide",
"type": "terminal",
"url": "https://github.com/ajeetdsouza/zoxide",
"description": "cd but with fuzzy matching and other cool features",
"demo": "<script src=\"https://asciinema.c.woodburn.au/a/5.js\" id=\"asciicast-5\" async=\"true\"></script>"
},
{
"name": "Atuin",
"type": "terminal",
"url": "https://atuin.sh/",
"description": "A next-generation shell history manager",
"demo": "<script src=\"https://asciinema.c.woodburn.au/a/6.js\" id=\"asciicast-6\" async=\"true\"></script>"
},
{
"name": "Tmate",
"type": "terminal",
"url": "https://tmate.io/",
"description": "Instant terminal sharing",
"demo": "<script src=\"https://asciinema.c.woodburn.au/a/7.js\" id=\"asciicast-7\" async=\"true\"></script>"
},
{
"name": "Eza",
"type": "terminal",
"url": "https://eza.rocks/",
"description": "A modern replacement for 'ls'",
"demo": "<script src=\"https://asciinema.c.woodburn.au/a/8.js\" id=\"asciicast-8\" async=\"true\"></script>"
},
{
"name": "Bat",
"type": "terminal",
"url": "https://github.com/sharkdp/bat",
"description": "A cat clone with syntax highlighting and Git integration",
"demo": "<script src=\"https://asciinema.c.woodburn.au/a/9.js\" id=\"asciicast-9\" async=\"true\"></script>"
},
{
"name": "Oh My Zsh",
"type": "terminal",
"url": "https://ohmyz.sh/",
"description": "A delightful community-driven framework for managing your Zsh configuration"
},
{
"name": "Proxmox",
"type": "Server Management",
"url": "https://www.proxmox.com/en",
"description": "Open-source server virtualization management solution"
},
{
"name": "Portainer",
"type": "Server Management",
"url": "https://www.portainer.io/",
"description": "Lightweight management UI which allows you to easily manage your Docker containers"
},
{
"name": "Coolify",
"type": "Server Management",
"url": "https://coolify.io/",
"description": "An open-source self-hosted Heroku alternative"
},
{
"name": "OpnSense",
"type": "Server Management",
"url": "https://opnsense.org/",
"description": "Open source, easy-to-use and easy-to-build FreeBSD based firewall and routing platform"
},
{
"name": "Nginx Proxy Manager",
"type": "Server Management",
"url": "https://nginxproxymanager.com/",
"description": "A powerful yet easy to use web interface for managing Nginx proxy hosts"
},
{
"name": "Tailscale",
"type": "Server Management",
"url": "https://tailscale.com/",
"description": "A zero-config VPN that just works"
},
{
"name": "Authentik",
"type": "Self-Hosting Services",
"url": "https://goauthentik.io/",
"description": "An open-source identity provider focused on flexibility and ease of use"
},
{
"name": "Uptime Kuma",
"type": "Self-Hosting Services",
"url": "https://uptime.kuma.pet/",
"description": "A fancy self-hosted monitoring tool"
},
{
"name": "Gitea",
"type": "Self-Hosting Services",
"url": "https://about.gitea.com/",
"description": "A painless self-hosted Git service"
},
{
"name": "Nextcloud",
"type": "Self-Hosting Services",
"url": "https://nextcloud.com/",
"description": "A suite of client-server software for creating and using file hosting services"
},
{
"name": "Umami",
"type": "Self-Hosting Services",
"url": "https://umami.is/",
"description": "A simple, fast, privacy-focused alternative to Google Analytics"
},
{
"name": "PhotoPrism",
"type": "Self-Hosting Services",
"url": "https://photoprism.app/",
"description": "AI-powered app for browsing, organizing & sharing your photo collection"
},
{
"name": "FreeScout",
"type": "Self-Hosting Services",
"url": "https://freescout.net/",
"description": "Self hosted email dashboard"
},
{
"name": "Vaultwarden",
"type": "Miscellaneous",
"url": "https://github.com/dani-garcia/vaultwarden",
"description": "Password manager server implementation compatible with Bitwarden clients"
}
]

View File

@@ -25,7 +25,8 @@ from blueprints.wellknown import wk_bp
from blueprints.api import api_bp
from blueprints.podcast import podcast_bp
from blueprints.acme import acme_bp
from tools import isCurl, isCrawler, getAddress, getFilePath, error_response, getClientIP, json_response, getGitCommit, isDev, getHandshakeScript
from tools import isCurl, isCrawler, getAddress, getFilePath, error_response, getClientIP, json_response, getHandshakeScript, get_tools_data
from curl import curl_response
app = Flask(__name__)
CORS(app)
@@ -242,14 +243,7 @@ def index():
if request.args.get("load"):
loaded = False
if isCurl(request):
return jsonify(
{
"message": "Welcome to Nathan.Woodburn/! This is a personal website. For more information, visit https://nathan.woodburn.au",
"ip": getClientIP(request),
"dev": isDev(request.host),
"version": getGitCommit()
}
)
return curl_response(request)
if not loaded and not isCrawler(request):
# Set cookie
@@ -399,10 +393,11 @@ def index():
return resp
# region Donate
@app.route("/donate")
def donate():
if isCurl(request):
return curl_response(request)
coinList = os.listdir(".well-known/wallets")
coinList = [file for file in coinList if file[0] != "."]
coinList.sort()
@@ -563,7 +558,6 @@ def qrcodee(data):
# endregion
@app.route("/supersecretpath")
def supersecretpath():
ascii_art = ""
@@ -689,6 +683,10 @@ def resume_pdf():
return send_file("data/resume.pdf")
return error_response(request, message="Resume not found")
@app.route("/tools")
def tools():
return render_template("tools.html", tools=get_tools_data())
# endregion
# region Error Catching
@@ -701,6 +699,10 @@ def catch_all(path: str):
if path.lower().replace(".html", "") in RESTRICTED_ROUTES:
return error_response(request, message="Restricted route", code=403)
# If curl request, return curl response
if isCurl(request):
return curl_response(request)
if path in REDIRECT_ROUTES:
return redirect(REDIRECT_ROUTES[path], code=302)

View File

@@ -1 +1 @@
:root,[data-bs-theme=light]{--bs-primary:#6E0E9C;--bs-primary-rgb:110,14,156;--bs-primary-text-emphasis:#2C063E;--bs-primary-bg-subtle:#E2CFEB;--bs-primary-border-subtle:#C59FD7;--bs-link-color:#6E0E9C;--bs-link-color-rgb:110,14,156;--bs-link-hover-color:#a41685;--bs-link-hover-color-rgb:164,22,133}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#6E0E9C;--bs-btn-border-color:#6E0E9C;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5E0C85;--bs-btn-hover-border-color:#580B7D;--bs-btn-focus-shadow-rgb:233,219,240;--bs-btn-active-color:#fff;--bs-btn-active-bg:#580B7D;--bs-btn-active-border-color:#530B75;--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6E0E9C;--bs-btn-disabled-border-color:#6E0E9C}.btn-outline-primary{--bs-btn-color:#6E0E9C;--bs-btn-border-color:#6E0E9C;--bs-btn-focus-shadow-rgb:110,14,156;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6E0E9C;--bs-btn-hover-border-color:#6E0E9C;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6E0E9C;--bs-btn-active-border-color:#6E0E9C;--bs-btn-disabled-color:#6E0E9C;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6E0E9C}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}@media (min-width:992px){.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}}
:root,[data-bs-theme=light]{--bs-primary:#6E0E9C;--bs-primary-rgb:110,14,156;--bs-primary-text-emphasis:#2C063E;--bs-primary-bg-subtle:#E2CFEB;--bs-primary-border-subtle:#C59FD7;--bs-link-color:#6E0E9C;--bs-link-color-rgb:110,14,156;--bs-link-hover-color:#a41685;--bs-link-hover-color-rgb:164,22,133}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#6E0E9C;--bs-btn-border-color:#6E0E9C;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5E0C85;--bs-btn-hover-border-color:#580B7D;--bs-btn-focus-shadow-rgb:233,219,240;--bs-btn-active-color:#fff;--bs-btn-active-bg:#580B7D;--bs-btn-active-border-color:#530B75;--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6E0E9C;--bs-btn-disabled-border-color:#6E0E9C}.btn-outline-primary{--bs-btn-color:#6E0E9C;--bs-btn-border-color:#6E0E9C;--bs-btn-focus-shadow-rgb:110,14,156;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6E0E9C;--bs-btn-hover-border-color:#6E0E9C;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6E0E9C;--bs-btn-active-border-color:#6E0E9C;--bs-btn-disabled-color:#6E0E9C;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6E0E9C}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}@media (min-width:992px){.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}}

14
templates/contact.ascii Normal file
View File

@@ -0,0 +1,14 @@
{{header}}
───────────────────────────────────────────────
 CONTACT ME 
────────────
Here are my socials — Im most active on Discord 💬
- Twitter: https://twitter.com/woodburn_nathan
- GitHub: https://github.com/Nathanwoodburn
- Email: mailto:about@nathan.woodburn.au
- Discord: https://l.woodburn.au/discord
- Mastodon: https://mastodon.woodburn.au/@nathanwoodburn
- YouTube: https://www.youtube.com/@nathanjwoodburn

25
templates/donate.ascii Normal file
View File

@@ -0,0 +1,25 @@
{{header}}
───────────────────────────────────────────────
 DONATE 
────────
If youd like to support my work 💙
- PayPal: [https://paypal.me/nathanwoodburn]
- GitHub: [https://github.com/sponsors/Nathanwoodburn]
- Stripe: [https://donate.stripe.com/8wM6pv0VD08Xe408ww]
HNS: nathan.woodburn
{{ HNS }}
BTC: thinbadger6@primal.net
{{ BTC }}
SOL: woodburn.sol
{{ SOL }}
ETH: woodburn.au
{{ ETH }}
More donation options → [/donate/more]

View File

@@ -0,0 +1,10 @@
{{header}}
───────────────────────────────────────────────
 DONATE 
────────
Here is my {{ coin }} address if you'd like to send a donation 💙
{{ address }}
Thank you for your support! 🙏

View File

@@ -0,0 +1,13 @@
{{header}}
───────────────────────────────────────────────
 DONATE 
────────
Here is a list of additional cryptocurrencies and donation methods 💙
For each coin below, you can get the address from /donate/<coin>
{% for coin in coins %}{% if loop.index0 % 4 == 0 and loop.index0 != 0 %}
{% endif %}{{ coin }}{% if not loop.last %}, {% endif %}{% endfor %}
Thank you for your support! 🙏

25
templates/favicon.ascii Normal file
View File

@@ -0,0 +1,25 @@
▒▒▒ ▓▓▓
▒░░░░▒▓ ▓▓▓▓▓▓▓
▒░░░░░░▒▒▒▒ ▓▓▓▓▓▓▓▓▓▓▓
▒░░░░░▒▒▒▒▒▒▒ ▓▓▒▓▓▓▓▓▓▓▓▓▓
▒░░░▒▒▒▒▒▒▒▒▒ ▓▓▓▓▓▓▓▓▓▓▓▓▓
▒░░▒▒▒▒▒▒▒▒▒▒ ▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒ ▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒ ▒▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒ ▓▒▒▒▒ ▒▒▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒ ▓▒▒▒▒▒▒ ▒▒▒▒▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒ ▒▒▒▒▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒ ▓▒▒▒▒▒▒▒▒▒ ▒▒▒▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒ ▓▒▒▒▒▒▒▒▒▒▒▒ ▒▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓█
▓▒▒▒▒▒▒▒▒▒▒▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▒▒▒▒▓▓▓ ▓▓▓▓▓▓▓▓█
▓▓▓▓ ▓▓▓█

11
templates/header.ascii Normal file
View File

@@ -0,0 +1,11 @@
─────────────────────────────────────────────────────
 . . , . . . .. / 
 |\ | _.-+-|_ _.._ | | _ _ _||_ . .._.._ / 
 | \|(_] | [ )(_][ ) * |/\|(_)(_)(_][_)(_|[ [ )/ 
─────────────────────────────────────────────────────
Home [/]
Contact [/contact]
Projects [/projects]
Donate [/donate]

28
templates/index.ascii Normal file
View File

@@ -0,0 +1,28 @@
{{header}}
───────────────────────────────────────────────
 ABOUT ME 
──────────
Hi, I'm Nathan Woodburn from Canberra, Australia.
I've been homeschooled through Year 12 and am now studying a
Bachelor of Computer Science.
I love building random projects, so this site is always evolving.
I'm also one of the founders of Handshake AU [https://hns.au],
working to grow Handshake adoption across Australia.
I'm currently working on: {{ repo | safe }}
───────────────────────────────────────────────
 SKILLS 
────────
- Linux servers & CLI
- DNS, DNSSEC, and Trustless SSL
- NGINX web servers
- Programming:
- Python 3
- C#
- Java
- Bash

7
templates/projects.ascii Normal file
View File

@@ -0,0 +1,7 @@
{{header}}
───────────────────────────────────────────────
 RECENT PROJECTS 
─────────────────
{{projects}}

View File

@@ -96,4 +96,7 @@
<url>
<loc>https://nathan.woodburn.au/resume</loc>
</url>
<url>
<loc>https://nathan.woodburn.au/tools</loc>
</url>
</urlset>

148
templates/tools.html Normal file
View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html data-bs-theme="light" lang="en-au">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Tools | Nathan.Woodburn/</title>
<meta name="theme-color" content="#000000">
<link rel="canonical" href="https://nathan.woodburn.au/tools">
<meta property="og:url" content="https://nathan.woodburn.au/tools">
<meta name="fediverse:creator" content="@nathanwoodburn@mastodon.woodburn.au">
<meta name="twitter:description" content="G'day, this is my personal website. You can find out about me or check out some of my projects.">
<meta property="og:title" content="Nathan.Woodburn/">
<meta name="twitter:card" content="summary">
<meta name="twitter:image" content="https://nathan.woodburn.au/assets/img/profile.jpg">
<meta property="og:type" content="website">
<meta name="twitter:title" content="Nathan.Woodburn/">
<meta property="og:description" content="G'day, this is my personal website. You can find out about me or check out some of my projects.">
<meta property="og:image" content="https://nathan.woodburn.au/assets/img/profile.jpg">
<meta name="description" content="Check out some tools I use">
<link rel="apple-touch-icon" type="image/png" sizes="180x180" href="/assets/img/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/img/favicon/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/img/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="180x180" href="/assets/img/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="/assets/img/favicon/android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/assets/img/favicon/android-chrome-512x512.png">
<link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css">
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lora:400,700,400italic,700italic&amp;display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Cabin:700&amp;display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Anonymous+Pro&amp;display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&amp;display=swap">
<link rel="stylesheet" href="/assets/fonts/font-awesome.min.css">
<link rel="stylesheet" href="/assets/css/styles.min.css">
<link rel="stylesheet" href="/assets/css/brand-reveal.min.css">
<link rel="stylesheet" href="/assets/css/profile.min.css">
<link rel="stylesheet" href="/assets/css/Social-Icons.min.css">
<link rel="me" href="https://mastodon.woodburn.au/@nathanwoodburn" />
<script async src="https://umami.woodburn.au/script.js" data-website-id="6a55028e-aad3-481c-9a37-3e096ff75589"></script>
</head>
<body id="page-top" data-bs-spy="scroll" data-bs-target="#mainNav" data-bs-offset="77">
<nav class="navbar navbar-expand-md fixed-top navbar-light" id="mainNav" style="background: var(--bs-navbar-hover-color);">
<div class="container-fluid"><a class="navbar-brand" href="/#">
<div style="padding-right: 1em;display: inline-flex;">
<div class="slider"><span>/</span></div><span class="brand">Nathan.Woodburn</span>
</div>
</a><button data-bs-toggle="collapse" class="navbar-toggler navbar-toggler-right" data-bs-target="#navbarResponsive" type="button" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation" value="Menu"><i class="fa fa-bars"></i></button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav ms-auto">
<li class="nav-item nav-link"><a class="nav-link" href="/">Home</a></li>
<li class="nav-item nav-link"><a class="nav-link" href="/hosting">Hosting</a></li>
<li class="nav-item nav-link"><a class="nav-link" href="/projects">Projects</a></li>
<li class="nav-item nav-link"><a class="nav-link" href="/blog">Blog</a></li>
<li class="nav-item nav-link"><a class="nav-link" href="/now">Now</a></li>
</ul>
</div>
</div>
</nav>
<header class="masthead" style="background: url(&quot;/assets/img/bg/projects.webp&quot;) bottom / cover no-repeat;height: auto;padding-top: 20px;">
<div style="margin-top: 150px;margin-bottom: 100px;">
<div class="container">
<div class="row">
<div class="col-lg-8 mx-auto">
<h1 class="brand-heading">Tools</h1>
<p>Here is a list of applications, tools and services I use regularly.</p>
</div>
</div>
</div>
</div>
</header>
<section class="text-center content-section" id="tools" style="padding-bottom: 100px;">
<div class="container">{% for type, tools_in_type in tools | groupby('type') %}
<h2 class="mt-4 mb-3 sticky-top bg-primary py-2 section-header" id="{{type}}">{{ type }}</h2>
<div class="row">
{% for tool in tools_in_type %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 shadow-sm transition-all" style="transition: transform 0.2s, box-shadow 0.2s;" onmouseover="this.style.transform='translateY(-5px)'; this.style.boxShadow='0 0.5rem 1rem rgba(0,0,0,0.15)';" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='';">
<div class="card-body d-flex flex-column">
<h4 class="card-title">{{tool.name}}</h4>
<p class="card-text">{{ tool.description }}</p>
<div class="btn-group gap-3 mt-auto" role="group">{% if tool.demo %}<button class="btn btn-primary" type="button" data-bs-target="#modal-{{tool.name}}" data-bs-toggle="modal" style="transition: transform 0.2s, background-color 0.2s;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">View Demo</button>{% endif %}<a class="btn btn-primary" role="button" href="{{tool.url}}" target="_blank" style="transition: transform 0.2s, background-color 0.2s;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">{{tool.name}} Website</a></div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Modals for this type -->
{% for tool in tools_in_type %}
{% if tool.demo %}
<div id="modal-{{tool.name}}" class="modal fade" role="dialog" tabindex="-1" style="z-index: 1055;">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{tool.name}}</h4><button class="btn-close" type="button" aria-label="Close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
{{ tool.demo | safe }}
</div>
<div class="modal-footer"><button class="btn btn-light" type="button" data-bs-dismiss="modal">Close</button></div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
{% endfor %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const navbar = document.getElementById('mainNav');
const headers = document.querySelectorAll('.section-header');
if (navbar) {
const navbarHeight = navbar.offsetHeight;
headers.forEach(header => {
header.style.top = navbarHeight + 'px';
header.style.zIndex = '100';
header.style.scrollMarginTop = navbarHeight + 'px';
});
// Handle hash navigation on page load
if (window.location.hash) {
setTimeout(() => {
const target = document.querySelector(window.location.hash);
if (target) {
window.scrollTo({
top: target.offsetTop - navbarHeight,
behavior: 'smooth'
});
}
}, 0);
}
}
});
</script></div>
</section>
<footer>
<div class="container text-center">
<p class="copyright">Copyright ©&nbsp;Nathan.Woodburn/ 2025</p>
</div>
</footer>{{handshake_scripts | safe}}
<script src="/assets/bootstrap/js/bootstrap.min.js"></script>
<script src="/assets/js/script.min.js"></script>
<script src="/assets/js/grayscale.min.js"></script>
<script src="/assets/js/hacker.min.js"></script>
</body>
</html>

View File

@@ -5,6 +5,7 @@ import datetime
from typing import Optional, Dict, Union, Tuple
import re
from dateutil.parser import parse
import json
# HTTP status codes
HTTP_OK = 200
@@ -250,3 +251,7 @@ def parse_date(date_groups: list[str]) -> str | None:
except (ValueError, TypeError):
return None
def get_tools_data():
with open("data/tools.json", "r") as f:
return json.load(f)