Compare commits
42 Commits
projects
...
9cf9067a64
Author | SHA1 | Date | |
---|---|---|---|
9cf9067a64 | |||
9f34c2d544 | |||
624cf64c3b | |||
3fe4659377 | |||
12b14da970 | |||
0271538b80 | |||
2696e635e2 | |||
17e3296774 | |||
fd07ddf976 | |||
cb01a25c7d | |||
5c5330d93f | |||
52eb6a9c85 | |||
72e1f9d9bf | |||
d347310dc4 | |||
d38bea0cc2 | |||
baabe90000 | |||
9d2f92060f | |||
49a291ba5a | |||
50de5ebd95 | |||
0f295956ca | |||
03f2ffb1f2 | |||
ea9e8825c3 | |||
643370f4f8 | |||
460c511de7 | |||
63a0fa1b22 | |||
1d42d1efa6 | |||
ed4bf51c1c | |||
f4999f4d19 | |||
1805d0531b | |||
ff3d5ef48d | |||
da1210d682 | |||
bd1488c78a | |||
89ef938af4 | |||
ca3825660b | |||
50501a3d37 | |||
613c906626 | |||
b91245c8a1 | |||
56e771c454 | |||
faa1c5437b | |||
66c8b16018 | |||
856528fbc4 | |||
781be0bab1 |
0
.gitattributes
vendored
Normal file → Executable file
0
.gitattributes
vendored
Normal file → Executable file
2
.gitignore
vendored
Executable file
2
.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
||||
__pycache__
|
||||
.vscode
|
5
.pylintrc
Executable file
5
.pylintrc
Executable file
@@ -0,0 +1,5 @@
|
||||
[FORMAT]
|
||||
max-line-length=140
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=import-error
|
0
Dockerfile
Normal file → Executable file
0
Dockerfile
Normal file → Executable file
133
Jenkinsfile
vendored
Executable file
133
Jenkinsfile
vendored
Executable file
@@ -0,0 +1,133 @@
|
||||
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: 'git@git.jakecharman.co.uk:jake/jc-ng.git'
|
||||
|
||||
sh "./build.sh registry.jakecharman.co.uk/jakecharman.co.uk $BUILD_NUMBER"
|
||||
}
|
||||
}
|
||||
|
||||
stage('Security scan') {
|
||||
steps {
|
||||
sh "docker run --name sectest registry.jakecharman.co.uk/jakecharman.co.uk:$BUILD_NUMNER"
|
||||
sh "docker exec sectest pip3 install pip-audit"
|
||||
sh "docker exec sectest pip-audit"
|
||||
}
|
||||
}
|
||||
|
||||
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: 'git@git.jakecharman.co.uk: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: 'git@git.jakecharman.co.uk:jake/jc-content.git'
|
||||
sh "rsync -rv --delete ./ /opt/containers/jc/projects/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
build.sh
Executable file
11
build.sh
Executable 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
|
0
config/httpd.conf
Normal file → Executable file
0
config/httpd.conf
Normal file → Executable file
12
run_local.sh
Executable file
12
run_local.sh
Executable 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
|
62
src/contact.py
Executable file
62
src/contact.py
Executable 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 - ')
|
55
src/index.py
Normal file → Executable file
55
src/index.py
Normal file → Executable file
@@ -1,17 +1,59 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import traceback
|
||||
from os import environ
|
||||
import threading
|
||||
import logging
|
||||
from requests import post
|
||||
from flask import Flask, render_template, Response
|
||||
|
||||
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('/')
|
||||
def index():
|
||||
def index() -> str:
|
||||
''' Load the homepage '''
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/error/<code>')
|
||||
def error(code):
|
||||
def error(code) -> str:
|
||||
''' Render a nicer error page for a given code '''
|
||||
|
||||
error_definitions = {
|
||||
400: 'Bad Request',
|
||||
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.'
|
||||
}
|
||||
|
||||
return render_template('error.html',
|
||||
if not code.isdigit():
|
||||
code=400
|
||||
elif int(code) not in error_definitions:
|
||||
return Response(status=code)
|
||||
|
||||
return render_template('error.html',
|
||||
error=f'{code}: {error_definitions.get(int(code))}',
|
||||
description=error_desc.get(int(code)))
|
||||
|
90
src/projects.py
Normal file → Executable file
90
src/projects.py
Normal file → Executable file
@@ -2,19 +2,22 @@
|
||||
|
||||
from os import path
|
||||
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 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 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/'))
|
||||
|
||||
@app.context_processor
|
||||
def processor():
|
||||
def get_excerpt(post):
|
||||
def processor() -> dict:
|
||||
''' Jninja processors '''
|
||||
def get_excerpt(post: frontmatter.Post) -> str:
|
||||
html = markdown(post.content)
|
||||
post_soup = BeautifulSoup(html, 'html.parser')
|
||||
all_text = ' '.join([x.get_text() for x in post_soup.findAll('p')])
|
||||
@@ -23,31 +26,37 @@ def processor():
|
||||
|
||||
@app.template_filter('category_title')
|
||||
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)
|
||||
|
||||
return categories.get(category_id).get('title', '')
|
||||
|
||||
@app.template_filter('human_date')
|
||||
def human_date(iso_date: str) -> str:
|
||||
''' Jninja filter to convert an ISO date to human readable. '''
|
||||
try:
|
||||
return datetime.fromisoformat(str(iso_date)).strftime('%A %d %B %Y')
|
||||
except ValueError:
|
||||
return iso_date
|
||||
|
||||
@app.template_filter('to_html')
|
||||
def to_html(content):
|
||||
def to_html(content: str) -> str:
|
||||
''' Jninja filter to wrap markdown '''
|
||||
return markdown(content)
|
||||
|
||||
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')]
|
||||
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, [])]
|
||||
|
||||
@app.route('/projects/')
|
||||
def projects():
|
||||
def projects() -> str:
|
||||
''' Load the projects page '''
|
||||
articles_to_return = sorted(
|
||||
get_all_posts(
|
||||
md_directory),
|
||||
@@ -60,7 +69,7 @@ def projects():
|
||||
error='There\'s nothing here... yet.',
|
||||
description='I\'m still working on this page. Check back soon for some content.')
|
||||
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)
|
||||
except FileNotFoundError:
|
||||
return render_template('error.html',
|
||||
@@ -71,14 +80,16 @@ def projects():
|
||||
articles=articles_to_return,
|
||||
all_categories=categories,
|
||||
title='Projects',
|
||||
page_title='Projects - ',
|
||||
description='A selection of projects I\'ve been involved in')
|
||||
|
||||
@app.route('/projects/category/<category>/')
|
||||
def category(category):
|
||||
@app.route('/projects/category/<category_id>/')
|
||||
def category(category_id: str) -> str:
|
||||
''' Load the page for a given category '''
|
||||
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)
|
||||
the_category = categories.get(category)
|
||||
the_category = categories.get(category_id)
|
||||
except FileNotFoundError:
|
||||
return render_template('error.html',
|
||||
error='There\'s nothing here... yet.',
|
||||
@@ -89,7 +100,7 @@ def category(category):
|
||||
|
||||
articles_to_return = sorted(
|
||||
get_by_meta_key(
|
||||
md_directory, 'categories', category),
|
||||
md_directory, 'categories', category_id),
|
||||
key=lambda d: d.metadata.get('date'),
|
||||
reverse=True
|
||||
)
|
||||
@@ -104,11 +115,12 @@ def category(category):
|
||||
description=the_category['long_description'],
|
||||
page_title=f'{the_category["title"]} - ',
|
||||
all_categories=categories,
|
||||
current_category=category)
|
||||
current_category=category_id)
|
||||
|
||||
@app.route('/projects/<article>')
|
||||
def article(article):
|
||||
articles = get_by_meta_key(md_directory, 'id', article)
|
||||
@app.route('/projects/<article_id>')
|
||||
def article(article_id: str) -> str:
|
||||
''' Load a single article '''
|
||||
articles = get_by_meta_key(md_directory, 'id', article_id)
|
||||
|
||||
if len(articles) == 0:
|
||||
return Response(status=404)
|
||||
@@ -120,6 +132,36 @@ def article(article):
|
||||
metadata=the_article.metadata,
|
||||
page_title=f'{the_article.metadata["title"]} - ')
|
||||
|
||||
@app.route('/projects/image/<image>')
|
||||
def image(image):
|
||||
return send_from_directory(path.join(md_directory, 'images'), image)
|
||||
@app.route('/projects/image/<image_name>')
|
||||
def image(image_name: str) -> Response:
|
||||
''' 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
|
||||
|
0
src/projects.wsgi
Normal file → Executable file
0
src/projects.wsgi
Normal file → Executable file
2
src/requirements.txt
Normal file → Executable file
2
src/requirements.txt
Normal file → Executable file
@@ -3,3 +3,5 @@ flask-markdown>=0.3
|
||||
markdown>=3.4.1
|
||||
beautifulsoup4>=4.11.1
|
||||
python-frontmatter>=1.1.0
|
||||
requests>=2.32.3
|
||||
pillow>=11.0.0
|
||||
|
0
src/static/fonts/fontawesome/css/all.min.css
vendored
Normal file → Executable file
0
src/static/fonts/fontawesome/css/all.min.css
vendored
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-brands-400.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-brands-400.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-brands-400.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-brands-400.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-regular-400.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-regular-400.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-regular-400.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-regular-400.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-solid-900.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-solid-900.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-solid-900.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-solid-900.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-v4compatibility.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-v4compatibility.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-v4compatibility.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-v4compatibility.woff2
Normal file → Executable file
BIN
src/static/images/jc.ico
Executable file
BIN
src/static/images/jc.ico
Executable file
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
0
src/static/images/njr-code.png
Normal file → Executable file
0
src/static/images/njr-code.png
Normal file → Executable file
0
src/static/images/topfuel_startline.jpg.jpeg
Normal file → Executable file
0
src/static/images/topfuel_startline.jpg.jpeg
Normal file → Executable file
0
src/static/js/filter_projects.js
Normal file → Executable file
0
src/static/js/filter_projects.js
Normal file → Executable file
0
src/static/js/update_copyright.js
Normal file → Executable file
0
src/static/js/update_copyright.js
Normal file → Executable file
13
src/static/style/desktop.css
Normal file → Executable file
13
src/static/style/desktop.css
Normal file → Executable file
@@ -42,6 +42,8 @@
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 auto 0 auto;
|
||||
width: 80vw;
|
||||
}
|
||||
|
||||
.project{
|
||||
@@ -53,5 +55,16 @@
|
||||
float: right;
|
||||
width: fit-content;
|
||||
padding-right: 20px;
|
||||
position: relative;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
#logo-container{
|
||||
position: absolute;
|
||||
height: 25vh;
|
||||
}
|
||||
|
||||
#article>p>img{
|
||||
display: inline;
|
||||
}
|
||||
}
|
50
src/static/style/mobile.css
Normal file → Executable file
50
src/static/style/mobile.css
Normal file → Executable file
@@ -30,6 +30,7 @@ footer{
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#logo{
|
||||
@@ -117,7 +118,9 @@ a{
|
||||
|
||||
.project-thumb{
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-height: 50%;
|
||||
display: block;
|
||||
margin: 0 auto 0 auto;
|
||||
}
|
||||
|
||||
#filter {
|
||||
@@ -167,4 +170,49 @@ a{
|
||||
|
||||
.article-category:hover{
|
||||
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%;
|
||||
}
|
2
src/templates/article.html
Normal file → Executable file
2
src/templates/article.html
Normal file → Executable file
@@ -1,6 +1,6 @@
|
||||
{% include 'header.html' %}
|
||||
<main>
|
||||
<section id="tech-article">
|
||||
<section id="article">
|
||||
<h1>{{ metadata.title}} </h1>
|
||||
<p>{{ metadata.date | human_date }}</p>
|
||||
<hr />
|
||||
|
20
src/templates/contact.html
Executable file
20
src/templates/contact.html
Executable 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' %}
|
0
src/templates/error.html
Normal file → Executable file
0
src/templates/error.html
Normal file → Executable file
0
src/templates/footer.html
Normal file → Executable file
0
src/templates/footer.html
Normal file → Executable file
12
src/templates/header.html
Normal file → Executable file
12
src/templates/header.html
Normal file → Executable file
@@ -2,6 +2,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400..900&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:wght@400..900&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="preconnect" href="https://fonts.googleapis.com">
|
||||
<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/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>
|
||||
<body>
|
||||
<header>
|
||||
<nav id="top-nav">
|
||||
<a href="/">About</a>
|
||||
<a href="/projects/">Projects</a>
|
||||
<a href="/contact/">Contact</a>
|
||||
</nav>
|
||||
<div id="logo-container">
|
||||
<div id="logo">
|
||||
|
0
src/templates/index.html
Normal file → Executable file
0
src/templates/index.html
Normal file → Executable file
27
src/templates/projects.html
Normal file → Executable file
27
src/templates/projects.html
Normal file → Executable file
@@ -12,20 +12,35 @@
|
||||
</nav>
|
||||
<h2>{{ title }}</h2>
|
||||
<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">
|
||||
{% for row in articles %}
|
||||
<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">
|
||||
{% if row.get('link') is not none %}
|
||||
<a href="{{ row.link }}">
|
||||
<h3>{{ row.title }}</h3>
|
||||
</a>
|
||||
<a href="{{ row.link }}">
|
||||
{% else %}
|
||||
<a href="/projects/{{ row.id }}">
|
||||
<a href="/projects/{{ row.id }}">
|
||||
{% endif %}
|
||||
<h3>{{ row.title }}</h3>
|
||||
</a>
|
||||
{% endif %}
|
||||
<p class="article-description">{{ row.description }}</p>
|
||||
<p class="article-date">{{ row.date | human_date }}</p>
|
||||
{% for category in row.categories %}
|
||||
|
Reference in New Issue
Block a user