40 Commits
Author SHA1 Message Date
jake 624cf64c3b Update Jenkinsfile 2025-05-01 21:21:25 +01:00
jake 3fe4659377 Fix mistakes 2025-03-25 14:44:38 +00:00
jake 12b14da970 Fix mistakes 2025-03-25 14:38:54 +00:00
jake 0271538b80 Fix mistakes 2025-03-25 14:37:16 +00:00
jake 2696e635e2 Sanity check error codes 2025-03-25 14:29:48 +00:00
jake 17e3296774 Fix image sizing 2025-03-08 11:44:00 +00:00
jake fd07ddf976 Remove typo 2025-03-08 11:17:39 +00:00
jake cb01a25c7d Fix category dropdown 2025-02-16 12:57:37 +00:00
jake 5c5330d93f Fix error 2025-02-16 12:56:03 +00:00
jake 52eb6a9c85 PEP-8 updates 2025-02-16 12:53:53 +00:00
jake 72e1f9d9bf Fix typo in Jenkinsfile 2025-02-16 12:14:56 +00:00
jake d347310dc4 Add Discord logging 2025-02-16 12:13:11 +00:00
jake d38bea0cc2 Add page titles 2025-02-15 22:57:23 +00:00
jake baabe90000 Fix Zone ID 2025-02-15 21:41:25 +00:00
jake 9d2f92060f Add gitignore 2025-02-15 21:38:53 +00:00
jake 49a291ba5a Add message to projects page 2025-02-15 21:38:04 +00:00
jake 50de5ebd95 Add message to projects page 2025-02-15 21:37:51 +00:00
jake 0f295956ca Fix container name 2025-02-15 21:31:44 +00:00
jake 03f2ffb1f2 Add Jenkinsfile 2025-02-15 21:29:39 +00:00
jake ea9e8825c3 Icon and description 2025-02-12 01:09:00 +00:00
jake 643370f4f8 Icon and description 2025-02-12 01:08:33 +00:00
jake 460c511de7 Fix helper 2025-02-04 21:09:17 +00:00
jake 63a0fa1b22 Fix helper 2025-02-04 21:08:43 +00:00
jake 1d42d1efa6 Fix helper 2025-02-04 21:06:03 +00:00
jake ed4bf51c1c Fix helper 2025-02-04 21:04:59 +00:00
jake f4999f4d19 add helper scripts 2025-02-04 21:01:10 +00:00
jake 1805d0531b Minor CSS Tweak to Project Images 2025-02-03 23:58:15 +00:00
jake ff3d5ef48d Add fallback for images PIL can't handle 2025-02-03 19:48:14 +00:00
jake da1210d682 fix image sizing 2025-02-02 23:21:41 +00:00
jake bd1488c78a Experimental change to image manipulation 2025-02-02 23:14:10 +00:00
jake 89ef938af4 reinstate error pages 2025-01-19 23:52:48 +00:00
jake ca3825660b Make project image clickable - fixed 2025-01-19 23:47:58 +00:00
jake 50501a3d37 Make project image clickable 2025-01-19 23:43:20 +00:00
jake 613c906626 Add Google analytics 2025-01-19 23:38:19 +00:00
jake b91245c8a1 Improvements to projects and dynamic image resizing 2025-01-19 21:19:45 +00:00
jake 56e771c454 Fix nav bug 2025-01-08 22:25:52 +00:00
jake faa1c5437b Merge pull request 'Create contact page' (#2) from contact into master
Reviewed-on: #2
2025-01-08 22:14:49 +00:00
jake 66c8b16018 Basic contact page 2025-01-08 22:12:10 +00:00
jake 856528fbc4 Begin building contact form 2025-01-08 19:57:42 +00:00
jake 781be0bab1 Merge pull request 'Create projects pages' (#1) from projects into master
Reviewed-on: #1
2025-01-04 00:18:38 +00:00
36 changed files with 452 additions and 36 deletions
Regular → Executable
View File
Executable
+2
View File
@@ -0,0 +1,2 @@
__pycache__
.vscode
Executable
+5
View File
@@ -0,0 +1,5 @@
[FORMAT]
max-line-length=140
[MESSAGES CONTROL]
disable=import-error
Regular → Executable
View File
Vendored Executable
+125
View File
@@ -0,0 +1,125 @@
pipeline {
agent any
environment {
TS = credentials('jc_turnstile')
DISCORD = credentials('jc_discord')
DISCORD_ERR_STAGING = credentials('jc_discord_err_staging')
DISCORD_ERR_PROD = credentials('jc_discord_err_prod')
}
stages{
stage ('Set up parameters') {
steps {
script{
properties ([
parameters([
booleanParam(defaultValue: true, description: 'Build from source', name: 'Build'),
booleanParam(defaultValue: true, description: 'Deploy to servers', name: 'Deploy'),
booleanParam(defaultValue: true, description: 'Update posts', name: 'Update'),
])
])
}
}
}
stage('Build') {
when {
expression {
return params.Build == true
}
}
steps {
git branch: 'master',
credentialsId: 'Git',
url: '[email protected]:jake/jc-ng.git'
sh "./build.sh registry.jakecharman.co.uk/jakecharman.co.uk $BUILD_NUMBER"
}
}
stage('Push to registry') {
when {
expression {
return params.Build == true
}
}
steps {
sh "docker push registry.jakecharman.co.uk/jakecharman.co.uk:$BUILD_NUMBER"
sh "docker push registry.jakecharman.co.uk/jakecharman.co.uk:latest"
}
}
stage('Deploy to staging server') {
when {
expression {
return params.Deploy == true
}
}
steps{
node('web-staging') {
sh "docker pull registry.jakecharman.co.uk/jakecharman.co.uk:latest"
sh "docker stop jake || true"
sh "docker rm jake || true"
sh "docker run --name jake -e DISCORD_ERR_HOOK=$DISCORD_ERR_STAGING -e DISCORD_WEBHOOK=$DISCORD -e TURNSTILE_SECRET=$TS --restart always --network containers_default -v /opt/containers/jc/projects/:/var/www/jc/projects/ -d registry.jakecharman.co.uk/jakecharman.co.uk:latest"
}
}
}
stage('Update content on staging server') {
when {
expression {
return params.Update == true
}
}
steps {
node('web-staging') {
git branch: 'master',
credentialsId: 'Git',
url: '[email protected]:jake/jc-content.git'
sh "rsync -rv --delete ./ /opt/containers/jc/projects/"
}
}
}
stage('Wait for confirmation to push to prod') {
steps {
timeout(time: 30, unit: 'MINUTES') {
input "Deploy to production?"
}
}
}
stage('Deploy to production server') {
when {
expression {
return params.Deploy == true
}
}
steps{
node('web-server') {
sh "docker pull registry.jakecharman.co.uk/jakecharman.co.uk:latest"
sh "docker stop jake || true"
sh "docker rm jake || true"
sh "docker run --name jake -e DISCORD_ERR_HOOK=$DISCORD_ERR_PROD -e DISCORD_WEBHOOK=$DISCORD -e TURNSTILE_SECRET=$TS --restart always --network containers_default -v /opt/containers/jc/projects/:/var/www/jc/projects/ -d registry.jakecharman.co.uk/jakecharman.co.uk:latest"
sh "/home/jenkins/clearCFCache/clearCache.py a514fb61e1413b88aabbb19df16b8508"
}
}
}
stage('Update content on production server') {
when {
expression {
return params.Update == true
}
}
steps {
node('web-server') {
git branch: 'master',
credentialsId: 'Git',
url: '[email protected]:jake/jc-content.git'
sh "rsync -rv --delete ./ /opt/containers/jc/projects/"
}
}
}
}
}
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash --noprofile
set -x -o pipefail
tag=$1
build=$2
docker build -t ${tag}:latest .
if [[ $build != "" ]]; then
docker build -t ${tag}:${build} .
fi
Regular → Executable
View File
Executable
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash --noprofile
set -x -o pipefail
./build.sh "jc-ng-localtest"
content_dir=""
if [[ -d "../jc-content" ]]; then
content_dir="-v $(realpath ../jc-content):/var/www/jc/projects"
fi
docker run -e DISCORD_ERR_HOOK=dummy $1 -v $(pwd)/src/:/var/www/jc $content_dir jc-ng-localtest
Executable
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/python3
from index import app
from os import environ
from flask import request, render_template
from requests import post, get
from uuid import uuid4
from textwrap import dedent
def validate_turnstile(response: str, ip: str) -> bool:
turnstile_secret = environ['TURNSTILE_SECRET']
cf_response = post(
url='https://challenges.cloudflare.com/turnstile/v0/siteverify',
data={
'secret': turnstile_secret,
'response': response,
'remoteip': ip,
'idempotency_key': uuid4()
}
).json()
return cf_response.get('success', False)
def send_to_discord(form: dict) -> bool:
try:
discord_hook = environ['DISCORD_WEBHOOK']
except:
return False
discord_msg = dedent(
f'''
__**New Contact Form Response**__
**From:** {form.get('name')} <{form.get('email')}>
''')
if form.get("message") == '':
discord_msg += '*No Message*'
else:
discord_msg += f'>>> {form.get("message")}'
discord_response = post(
url=discord_hook,
data={
'username': form.get('name'),
'content': discord_msg
}
).status_code
if discord_response == 204:
return True
return False
@app.route('/contact/', methods=('GET', 'POST'))
def contact():
if request.method == 'POST':
if not validate_turnstile(request.form['cf-turnstile-response'], request.remote_addr):
return render_template('contact.html', error=True, user_message='You appear to be a robot.')
send_result = send_to_discord(request.form)
if send_result:
return render_template('contact.html', user_message='Your message has been sent!')
return render_template('contact.html', error=True, user_message='An error occurred.')
else:
return render_template('contact.html', page_title='Contact - ')
Regular → Executable
+50 -3
View File
@@ -1,17 +1,59 @@
#!/usr/bin/python3 #!/usr/bin/python3
import traceback
from os import environ
import threading
import logging
from requests import post
from flask import Flask, render_template, Response from flask import Flask, render_template, Response
app = Flask(__name__) app = Flask(__name__)
import projects # These imports need to come after our app is defined as they add routes to it.
import projects # pylint: disable=wrong-import-position,unused-import
import contact # pylint: disable=wrong-import-position,unused-import
class DiscordLogger(logging.Handler):
''' Simple logging handler to send a message to Discord '''
level = logging.ERROR
def __init__(self, webhook):
super().__init__()
self._webhook = webhook
def send_to_discord(self, msg: logging.LogRecord):
''' Send the message '''
if msg.exc_info is not None:
message_to_send = f'{msg.msg}\n\n{"".join(traceback.format_exception(*msg.exc_info))}'
else:
message_to_send = msg.msg
if len(message_to_send) > 2000:
chars_to_lose = len(message_to_send) - 2000
if msg.exc_info is not None:
message_to_send = f'{msg.msg}\n\n{"".join(traceback.format_exception(*msg.exc_info))[-chars_to_lose:]}'
else:
message_to_send = msg.msg[-chars_to_lose:]
post(self._webhook, data={'content': message_to_send}, timeout=30)
def emit(self, record: logging.LogRecord) -> None:
''' Take in the record and start a new thread to send it to Discord '''
app.logger.info('Sending error to Discord')
dc_thread = threading.Thread(target=self.send_to_discord, args=[record])
dc_thread.start()
discord_logger = DiscordLogger(environ['DISCORD_ERR_HOOK'])
app.logger.addHandler(discord_logger)
@app.route('/') @app.route('/')
def index(): def index() -> str:
''' Load the homepage '''
return render_template('index.html') return render_template('index.html')
@app.route('/error/<code>') @app.route('/error/<code>')
def error(code): def error(code) -> str:
''' Render a nicer error page for a given code '''
error_definitions = { error_definitions = {
400: 'Bad Request', 400: 'Bad Request',
403: 'Forbidden', 403: 'Forbidden',
@@ -31,6 +73,11 @@ def error(code):
505: 'Your browser tried to use a HTTP version I don\'t support. Check it is up to date.' 505: 'Your browser tried to use a HTTP version I don\'t support. Check it is up to date.'
} }
if not code.isdigit():
code=400
elif int(code) not in error_definitions:
return Response(status=code)
return render_template('error.html', return render_template('error.html',
error=f'{code}: {error_definitions.get(int(code))}', error=f'{code}: {error_definitions.get(int(code))}',
description=error_desc.get(int(code))) description=error_desc.get(int(code)))
Regular → Executable
+66 -24
View File
@@ -2,19 +2,22 @@
from os import path from os import path
import json import json
from flask import Flask, render_template, Response, send_from_directory
from markdown import markdown
import frontmatter
from glob import glob
from datetime import datetime from datetime import datetime
from index import app from io import BytesIO
from glob import glob
from PIL import Image, UnidentifiedImageError
import frontmatter
from markdown import markdown
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from flask import render_template, Response, send_from_directory, request, make_response
from index import app
md_directory = path.join(path.realpath(path.dirname(__file__)), path.normpath('projects/')) md_directory = path.join(path.realpath(path.dirname(__file__)), path.normpath('projects/'))
@app.context_processor @app.context_processor
def processor(): def processor() -> dict:
def get_excerpt(post): ''' Jninja processors '''
def get_excerpt(post: frontmatter.Post) -> str:
html = markdown(post.content) html = markdown(post.content)
post_soup = BeautifulSoup(html, 'html.parser') post_soup = BeautifulSoup(html, 'html.parser')
all_text = ' '.join([x.get_text() for x in post_soup.findAll('p')]) all_text = ' '.join([x.get_text() for x in post_soup.findAll('p')])
@@ -23,31 +26,37 @@ def processor():
@app.template_filter('category_title') @app.template_filter('category_title')
def category_title(category_id: str) -> str: def category_title(category_id: str) -> str:
with open(path.join(md_directory, 'categories.json')) as categories_file: ''' Jninja filter to get a category title by its ID '''
with open(path.join(md_directory, 'categories.json'), encoding='utf8') as categories_file:
categories = json.load(categories_file) categories = json.load(categories_file)
return categories.get(category_id).get('title', '') return categories.get(category_id).get('title', '')
@app.template_filter('human_date') @app.template_filter('human_date')
def human_date(iso_date: str) -> str: def human_date(iso_date: str) -> str:
''' Jninja filter to convert an ISO date to human readable. '''
try: try:
return datetime.fromisoformat(str(iso_date)).strftime('%A %d %B %Y') return datetime.fromisoformat(str(iso_date)).strftime('%A %d %B %Y')
except ValueError: except ValueError:
return iso_date return iso_date
@app.template_filter('to_html') @app.template_filter('to_html')
def to_html(content): def to_html(content: str) -> str:
''' Jninja filter to wrap markdown '''
return markdown(content) return markdown(content)
def get_all_posts(directory: str) -> list: def get_all_posts(directory: str) -> list:
''' Get all posts in the posts directory '''
abs_paths = [path.join(directory, x) for x in glob(f'{directory}/*.md')] abs_paths = [path.join(directory, x) for x in glob(f'{directory}/*.md')]
return [frontmatter.load(x) for x in abs_paths] return [frontmatter.load(x) for x in abs_paths]
def get_by_meta_key(directory: str, key: str, value: str): def get_by_meta_key(directory: str, key: str, value: str) -> list:
''' Get posts by a metadata key value pair '''
return [x for x in get_all_posts(directory) if x.get(key) == value or type(x.get(key, [])) is list and value in x.get(key, [])] return [x for x in get_all_posts(directory) if x.get(key) == value or type(x.get(key, [])) is list and value in x.get(key, [])]
@app.route('/projects/') @app.route('/projects/')
def projects(): def projects() -> str:
''' Load the projects page '''
articles_to_return = sorted( articles_to_return = sorted(
get_all_posts( get_all_posts(
md_directory), md_directory),
@@ -60,7 +69,7 @@ def projects():
error='There\'s nothing here... yet.', error='There\'s nothing here... yet.',
description='I\'m still working on this page. Check back soon for some content.') description='I\'m still working on this page. Check back soon for some content.')
try: try:
with open(path.join(md_directory, 'categories.json')) as categories_file: with open(path.join(md_directory, 'categories.json'), encoding='utf8') as categories_file:
categories = json.load(categories_file) categories = json.load(categories_file)
except FileNotFoundError: except FileNotFoundError:
return render_template('error.html', return render_template('error.html',
@@ -71,14 +80,16 @@ def projects():
articles=articles_to_return, articles=articles_to_return,
all_categories=categories, all_categories=categories,
title='Projects', title='Projects',
page_title='Projects - ',
description='A selection of projects I\'ve been involved in') description='A selection of projects I\'ve been involved in')
@app.route('/projects/category/<category>/') @app.route('/projects/category/<category_id>/')
def category(category): def category(category_id: str) -> str:
''' Load the page for a given category '''
try: try:
with open(path.join(md_directory, 'categories.json')) as categories_file: with open(path.join(md_directory, 'categories.json'), encoding='utf8') as categories_file:
categories = json.load(categories_file) categories = json.load(categories_file)
the_category = categories.get(category) the_category = categories.get(category_id)
except FileNotFoundError: except FileNotFoundError:
return render_template('error.html', return render_template('error.html',
error='There\'s nothing here... yet.', error='There\'s nothing here... yet.',
@@ -89,7 +100,7 @@ def category(category):
articles_to_return = sorted( articles_to_return = sorted(
get_by_meta_key( get_by_meta_key(
md_directory, 'categories', category), md_directory, 'categories', category_id),
key=lambda d: d.metadata.get('date'), key=lambda d: d.metadata.get('date'),
reverse=True reverse=True
) )
@@ -104,11 +115,12 @@ def category(category):
description=the_category['long_description'], description=the_category['long_description'],
page_title=f'{the_category["title"]} - ', page_title=f'{the_category["title"]} - ',
all_categories=categories, all_categories=categories,
current_category=category) current_category=category_id)
@app.route('/projects/<article>') @app.route('/projects/<article_id>')
def article(article): def article(article_id: str) -> str:
articles = get_by_meta_key(md_directory, 'id', article) ''' Load a single article '''
articles = get_by_meta_key(md_directory, 'id', article_id)
if len(articles) == 0: if len(articles) == 0:
return Response(status=404) return Response(status=404)
@@ -120,6 +132,36 @@ def article(article):
metadata=the_article.metadata, metadata=the_article.metadata,
page_title=f'{the_article.metadata["title"]} - ') page_title=f'{the_article.metadata["title"]} - ')
@app.route('/projects/image/<image>') @app.route('/projects/image/<image_name>')
def image(image): def image(image_name: str) -> Response:
return send_from_directory(path.join(md_directory, 'images'), image) ''' Resize and return an image. '''
w = int(request.args.get('w', 0))
h = int(request.args.get('h', 0))
if w == 0 and h == 0:
return send_from_directory(md_directory, path.join('images', image_name))
try:
the_image = Image.open(path.join(md_directory, 'images', image_name))
except FileNotFoundError:
return Response(status=404)
except UnidentifiedImageError:
return send_from_directory(md_directory, path.join('images', image_name))
max_width, max_height = the_image.size
if (w >= max_width and h >= max_height):
return send_from_directory(md_directory, path.join('images', image_name))
req_size = [max_width, max_height]
if w > 0:
req_size[0] = w
if h > 0:
req_size[1] = h
resized_img = BytesIO()
the_image.thumbnail(tuple(req_size))
the_image.save(resized_img, format=the_image.format)
response = make_response(resized_img.getvalue())
response.headers.set('Content-Type', f'image/{the_image.format}')
return response
Regular → Executable
View File
Regular → Executable
+2
View File
@@ -3,3 +3,5 @@ flask-markdown>=0.3
markdown>=3.4.1 markdown>=3.4.1
beautifulsoup4>=4.11.1 beautifulsoup4>=4.11.1
python-frontmatter>=1.1.0 python-frontmatter>=1.1.0
requests>=2.32.3
pillow>=11.0.0
Vendored Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Regular → Executable
View File
View File
Regular → Executable
View File
View File
Regular → Executable
+13
View File
@@ -42,6 +42,8 @@
justify-content: center; justify-content: center;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
margin: 0 auto 0 auto;
width: 80vw;
} }
.project{ .project{
@@ -53,5 +55,16 @@
float: right; float: right;
width: fit-content; width: fit-content;
padding-right: 20px; padding-right: 20px;
position: relative;
z-index: 999;
}
#logo-container{
position: absolute;
height: 25vh;
}
#article>p>img{
display: inline;
} }
} }
Regular → Executable
+49 -1
View File
@@ -30,6 +30,7 @@ footer{
width: 100%; width: 100%;
justify-content: center; justify-content: center;
display: flex; display: flex;
align-items: center;
} }
#logo{ #logo{
@@ -117,7 +118,9 @@ a{
.project-thumb{ .project-thumb{
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 50%;
display: block;
margin: 0 auto 0 auto;
} }
#filter { #filter {
@@ -168,3 +171,48 @@ a{
.article-category:hover{ .article-category:hover{
text-decoration: underline; text-decoration: underline;
} }
#contact-main{
padding: 20px 10px 0 10px;
min-height: 65vh;
}
#contact-main>h2 {
margin-top: 0;
}
input, textarea{
display: block;
padding: 10px;
margin-bottom: 20px;
background-color: #4c4c4c;
border-radius: 10px;
border:none;
color: white;
width: 100%;
}
label{
line-height: 2rem;
}
.cf-turnstile{
width: fit-content;
padding: 10px 0 10px 0;
margin-left: auto;
margin-right: auto;
}
#contact-error{
color: red;
}
#article{
padding: 0 10px 0 10px;
}
#article>p>img{
display: block;
margin: 0 auto 0 auto;
max-width: 100%;
}
Regular → Executable
+1 -1
View File
@@ -1,6 +1,6 @@
{% include 'header.html' %} {% include 'header.html' %}
<main> <main>
<section id="tech-article"> <section id="article">
<h1>{{ metadata.title}} </h1> <h1>{{ metadata.title}} </h1>
<p>{{ metadata.date | human_date }}</p> <p>{{ metadata.date | human_date }}</p>
<hr /> <hr />
+20
View File
@@ -0,0 +1,20 @@
{% include 'header.html' %}
<main id="contact-main">
<h2>Contact Me</h2>
<p>Got a question or want to talk about something on this site? Drop me a message below:</p>
<form action="#" method="post">
<label for="name">Name:</label>
<input type="text" name="name" required>
<label for="email">Email Address:</label>
<input type="email" name="email" required>
<label for="message">Message:</label>
<textarea name="message" rows="10"></textarea>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAA45FeR26JuvqKy7"></div>
<input type="submit" name="submit" value="Submit">
</form>
{% if user_message is not none %}
<p id="{{ 'contact-error' if error else 'contact-message' }}">{{ user_message }}</p>
{% endif %}
</main>
{% include 'footer.html' %}
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+12
View File
@@ -2,6 +2,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Personal website of Jake Charman. A technology professional based in the UK.">
<title>{{ page_title }}Jake Charman</title> <title>{{ page_title }}Jake Charman</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:[email protected]&family=Tourney:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Orbitron:[email protected]&family=Tourney:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Orbitron:[email protected]&family=Tourney:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Orbitron:[email protected]&family=Tourney:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
@@ -10,14 +11,25 @@
<link rel="stylesheet" href="/static/fonts/fontawesome/css/all.min.css" /> <link rel="stylesheet" href="/static/fonts/fontawesome/css/all.min.css" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="icon" type="image/x-icon" href="/static/images/jc.ico">
<script src="/static/js/filter_projects.js"></script> <script src="/static/js/filter_projects.js"></script>
<script src="/static/js/update_copyright.js"></script> <script src="/static/js/update_copyright.js"></script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-6WMXXY0RL0"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-6WMXXY0RL0');
</script>
</head> </head>
<body> <body>
<header> <header>
<nav id="top-nav"> <nav id="top-nav">
<a href="/">About</a> <a href="/">About</a>
<a href="/projects/">Projects</a> <a href="/projects/">Projects</a>
<a href="/contact/">Contact</a>
</nav> </nav>
<div id="logo-container"> <div id="logo-container">
<div id="logo"> <div id="logo">
Regular → Executable
View File
Regular → Executable
+19 -4
View File
@@ -12,20 +12,35 @@
</nav> </nav>
<h2>{{ title }}</h2> <h2>{{ title }}</h2>
<p>{{ description }}</p> <p>{{ description }}</p>
<p style="text-align: center; font-weight: bold; font-style: italic;">This page is relatively new. I'm adding projects as fast as I can but feel free to <a href="/contact">contact me</a> if you want to know about something in particular.</p>
<section id="projects"> <section id="projects">
{% for row in articles %} {% for row in articles %}
<div class="project"> <div class="project">
<img class="project-thumb" src="/projects/image/{{ row.image }}"> {% if row.get('link') is not none %}
<a href="{{ row.link }}">
{% else %}
<a href="/projects/{{ row.id }}">
{% endif %}
<img class="project-thumb"
srcset="
{% for i in range(200, 5100, 100) %}
/projects/image/{{ row.image }}?w={{i}} {{i}}w{{"," if not loop.last}}
{% endfor %}
"
sizes="
(max-width: 999px) 80vw,
(min-width: 1000px) 20vw
"
src="/projects/image/{{ row.image }}">
</a>
<div class="project-text"> <div class="project-text">
{% if row.get('link') is not none %} {% if row.get('link') is not none %}
<a href="{{ row.link }}"> <a href="{{ row.link }}">
<h3>{{ row.title }}</h3>
</a>
{% else %} {% else %}
<a href="/projects/{{ row.id }}"> <a href="/projects/{{ row.id }}">
{% endif %}
<h3>{{ row.title }}</h3> <h3>{{ row.title }}</h3>
</a> </a>
{% endif %}
<p class="article-description">{{ row.description }}</p> <p class="article-description">{{ row.description }}</p>
<p class="article-date">{{ row.date | human_date }}</p> <p class="article-date">{{ row.date | human_date }}</p>
{% for category in row.categories %} {% for category in row.categories %}