Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
2a9bf30
gitignore uv files
Feb 20, 2026
b46ea8b
implement async to avoid warnings
Feb 20, 2026
4cbf0cf
working invite page w/ empty form to unlock
Feb 20, 2026
6898d6f
add initial design
Feb 20, 2026
95fb9c8
implement notion + more styling
Feb 20, 2026
62eb755
Merge branch 'master' into wedding/invite
Feb 20, 2026
2505d6d
add favicon
Feb 20, 2026
9bc4039
change format of question/answer
Feb 20, 2026
25cb148
fix parallax jitter on mobile
Feb 20, 2026
7c343b0
update font for better mobile readability
Feb 20, 2026
2c49028
increase sizing for better readability
Feb 20, 2026
e96b55f
tweak shape aesthetics across platforms
Feb 20, 2026
d95c538
update readme
Feb 20, 2026
4ce3899
replace translateY w translate3d as potential fix mobile parallax lag
Feb 22, 2026
b71ad1d
add img & credits
Feb 22, 2026
81517da
set max-width of body
pmhalvor Feb 27, 2026
e704067
rm trailing bracket
pmhalvor Feb 27, 2026
ebf87cc
attempt fix: internal error on fresh load
Mar 5, 2026
bae0265
add .hide folder to gitignore
Jul 30, 2026
c86625f
add initial album parser & check
Jul 30, 2026
f2b3af8
add initial album parser & check
Jul 30, 2026
c4b5fea
add changes to previous commit
Jul 30, 2026
e0eb14b
move breakpoint before parsing
Jul 30, 2026
4e246a0
move breakpoint before parser
Jul 30, 2026
2c00bb7
fix parsing + move breakpoint to after parser
Jul 30, 2026
5fd8794
add update/create method to add new page to db
Jul 30, 2026
b7008e0
fix how data is passed to client
Jul 30, 2026
e450939
add more verbose printing around create and a final breakpoint
Jul 30, 2026
66f81d7
add working create to update database method
Jul 30, 2026
2e81908
add funtion to update db with local files
Jul 30, 2026
941372f
skip album check
Jul 30, 2026
16bf879
run update album
Jul 30, 2026
25506ae
look for JPG not .jpg
Jul 30, 2026
6b35f63
update url to inside static
Jul 30, 2026
fc3406e
remove break, run for all photos in album
pmhalvor Jul 30, 2026
4a74584
add page for wedding album
Jul 30, 2026
c6f83e1
Merge branch 'wedding/invite' of github.com:pmhalvor/website into wed…
Jul 30, 2026
efa8033
mount album to avoid packing into docker image
Jul 30, 2026
5c954fa
update static name for path
Jul 30, 2026
057c2bf
hide album
pmhalvor Jul 30, 2026
907661c
update album path in docker stuff
Jul 30, 2026
c8be121
update db with new path
pmhalvor Jul 30, 2026
1ef1d77
paginate photo album results
Jul 30, 2026
f66d9c9
protect images
Aug 3, 2026
12e1c8f
update dock ign
pmhalvor Aug 3, 2026
b7baca4
load more than 100 items from db
Aug 3, 2026
34e7cbe
add is_hidden option
Aug 3, 2026
802ab66
increase efficiency to better handle more traffic
Aug 3, 2026
78c8221
update gha checkout and setup-pytohn versions
Aug 17, 2026
cdaa9ba
relax pillow version in attempt to fix failing gha
Aug 17, 2026
5d65fe1
hide img
Aug 17, 2026
ac3b55e
small changes to album background
Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
home/protected/img/wedding_album/
home/static/img/wedding_album/
6 changes: 3 additions & 3 deletions .github/workflows/test-branch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@v7
with:
python-version: '3.x'
python-version: '3.13'

- name: Install dependencies
run: |
Expand Down
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
/siteenv

# uv stuff
.venv
.python-version
pyproject.toml
uv.lock

*/.data
sitedb
.cache
Expand All @@ -24,3 +30,8 @@ entry*
*/worker/store/duration_df.pkl

notion_cache/

*/.hide/
home/static/img/wedding_album/
home/protected/*
home/static/img/frank_og_per*.jpg
10 changes: 9 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
"workbench.colorCustomizations": {
"activityBar.background": "#8e9bec",
"titleBar.activeBackground": "#7e9fee",
"titleBar.activeForeground": "#291405"
"titleBar.activeForeground": "#291405",
"titleBar.inactiveBackground": "#142279",
"titleBar.inactiveForeground": "#0B1445",
"statusBar.background": "#17278A",
"statusBar.foreground": "#FBFCFE",
"statusBar.debuggingBackground": "#17278A",
"statusBar.debuggingForeground": "#FBFCFE",
"statusBar.noFolderBackground": "#17278A",
"statusBar.noFolderForeground": "#FBFCFE"
}
}
2 changes: 1 addition & 1 deletion Dockerfile.home
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ ENV PORT=${PORT}

EXPOSE ${PORT}

CMD ["sh", "-c", "gunicorn -b 0.0.0.0:${PORT} app:app"]
CMD ["sh", "-c", "gunicorn -b 0.0.0.0:${PORT} -w 4 app:app"]
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ Uses `docker compose` to build, run, then tear down the site container.
make run
```

## Manual rebuild
```sh
docker compose build home && docker kill home && docker compose up home -d
```

## Run locally for development
Navigate to `new_home/` and run the app:
```sh
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ services:
- .env
environment:
DEBUG: "false"
volumes:
- ./home/protected:/home/protected
platform: linux/amd64

136 changes: 122 additions & 14 deletions home/app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
from flask import Flask, render_template, jsonify
from notion import CachedNotionClient, parse_about_results, parse_cv_results, parse_notes_results
import math
import os
import asyncio
import sys
from urllib.parse import urlencode

# # Fix: Windows ProactorEventLoop causes 'Event loop is closed' errors with httpx/anyio
# if sys.platform == 'win32':
# asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

from flask import Flask, abort, render_template, jsonify, send_from_directory
from notion import CachedNotionClient
from notion import parse_notes_results, parse_about_results, parse_cv_results, parse_invite_wedding_results, parse_album_results
from config import Env
from flask import request
from flask import request, redirect
from invite import check_wedding_invite


env = Env(".env")
Expand All @@ -12,30 +24,35 @@
RADIO_URL = env.radio_url
CACHE_DIR = "./notion_cache"
CACHE_TTL = 3600
PROTECTED_DIR = os.path.join(app.root_path, "protected", "img")


notion_client = CachedNotionClient(token=NOTION_TOKEN, cache_dir=CACHE_DIR, cache_ttl=CACHE_TTL)

@app.route('/')
def home():
updates_data = notion_client.get_database(env.notion_sitedb_update_id)
async def home():
updates_data = await notion_client.get_database(env.notion_sitedb_update_id)
updates = parse_notes_results(updates_data['results'])
return render_template('index.html', updates=updates[:7]) # most recent 7


@app.route('/updates')
def updates():
updates_data = notion_client.get_database(env.notion_sitedb_update_id)
async def updates():
updates_data = await notion_client.get_database(env.notion_sitedb_update_id)
updates = parse_notes_results(updates_data['results'])
return render_template('updates.html', updates=updates)


@app.route('/about')
def about():
about_data = notion_client.get_database(env.notion_sitedb_about_id)
async def about():
about_data = await notion_client.get_database(env.notion_sitedb_about_id)
about = parse_about_results(about_data['results'])
return render_template('about.html', about=about)


@app.route('/cv')
def cv():
cv_data = notion_client.get_database(env.notion_sitedb_cv_id)
async def cv():
cv_data = await notion_client.get_database(env.notion_sitedb_cv_id)
cv = parse_cv_results(cv_data['results'])
# Separate into categories like in Django views
work = [item for item in cv if item['category'] == 'Work']
Expand All @@ -46,8 +63,8 @@ def cv():


@app.route('/notes')
def notes():
notes_data = notion_client.get_database(env.notion_sitedb_notes_id)
async def notes():
notes_data = await notion_client.get_database(env.notion_sitedb_notes_id)
notes = parse_notes_results(notes_data['results'])
return render_template("notes.html", notes=notes)

Expand All @@ -72,6 +89,97 @@ def callback():
return jsonify({'error': 'No code parameter found'}), 400


@app.route('/invite/wedding')
async def wedding_invite_empty():
"""
If user does not provide query parameters,
present a simple for to answer questions about the wedding.

These will be used as the key/value pairs to render the invite.

who,
when,
where
"""

return render_template("invite_wedding_empty.html")


@app.route('/invite/wedding/')
async def wedding_invite():
"""
Check query parameters.

If false, redirect to empty invite page.
"""
if not check_wedding_invite(request.args, env):
# redirect to empty invite page
return redirect('/invite/wedding')

invite_wedding_data = None
retries = 0
while invite_wedding_data is None:
invite_wedding_data = await notion_client.get_database(env.notion_sitedb_invite_wedding_id)
await asyncio.sleep(1) # wait a bit before retrying
retries += 1
if retries > 5: # give up after 5 retries
return "Error fetching invite data. Please refresh or try again later.", 500

invite_wedding = parse_invite_wedding_results(invite_wedding_data['results'])
return render_template('invite_wedding.html', invite_wedding=invite_wedding)



@app.route('/wedding/album')
async def wedding_album():
"""
Check query parameters.

If false, redirect to empty invite page.
"""
if not check_wedding_invite(request.args, env):
# redirect to empty invite page
return redirect('/invite/wedding')


wedding_album_data = await notion_client.get_database_all(env.notion_sitedb_wedding_album_id)
if not wedding_album_data:
return "Error fetching album data. Please refresh or try again later.", 500


auth_params = {k: request.args[k] for k in ['who', 'when', 'where', 'activity'] if k in request.args}
all_photos = parse_album_results(wedding_album_data['results'], auth_params)

photos_per_page = 5
total_pages = max(1, math.ceil(len(all_photos) / photos_per_page))

print(f"Total photos: {len(all_photos)}, Photos per page: {photos_per_page}, Total pages: {total_pages}")

try:
page = int(request.args.get('page', 0))
except ValueError:
page = 0
page = page % total_pages

album = all_photos[page * photos_per_page:(page + 1) * photos_per_page]

prev_url = '/wedding/album?' + urlencode({**auth_params, 'page': (page - 1) % total_pages})
next_url = '/wedding/album?' + urlencode({**auth_params, 'page': (page + 1) % total_pages})

return render_template('wedding_album.html', album=album, page=page, total_pages=total_pages, prev_url=prev_url, next_url=next_url)


@app.route("/hidden/<path:filename>")
def hidden_img(filename):
if not check_wedding_invite(request.args, env):
abort(403)

response = send_from_directory(PROTECTED_DIR, filename)
response.headers["Cache-Control"] = "public, max-age=86400"
return response



if __name__ == '__main__':
import os
app.run(port=os.environ.get('PORT', 5001)) # TODO test other ports
app.run(port=int(os.environ.get('PORT', 5003)), debug=True) # TODO test other ports
21 changes: 21 additions & 0 deletions home/invite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from config import Env


def check_wedding_invite(args: dict, env: Env):
"""
Check if the query parameters match the expected values for the wedding invite.

Returns True if all parameters match, False otherwise.
"""

expected_who = (env.wedding_invite_who).upper()
expected_when = (env.wedding_invite_when).upper()
expected_where = (env.wedding_invite_where).upper()
expected_activity = (env.wedding_invite_activity).upper()

return (
args.get("who", "").upper() == expected_who and
args.get("when", "").upper() == expected_when and
args.get("where", "").upper() == expected_where and
args.get("activity", "").upper() == expected_activity
)
Loading