diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ff9dafe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +home/protected/img/wedding_album/ +home/static/img/wedding_album/ diff --git a/.github/workflows/test-branch.yml b/.github/workflows/test-branch.yml index 007af52..8779b7f 100644 --- a/.github/workflows/test-branch.yml +++ b/.github/workflows/test-branch.yml @@ -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: | diff --git a/.gitignore b/.gitignore index 825a4a0..e241010 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ /siteenv +# uv stuff +.venv +.python-version +pyproject.toml +uv.lock + */.data sitedb .cache @@ -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 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index ca761f9..6d67b4f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" } } \ No newline at end of file diff --git a/Dockerfile.home b/Dockerfile.home index bd1f940..fe30bdf 100644 --- a/Dockerfile.home +++ b/Dockerfile.home @@ -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"] diff --git a/README.md b/README.md index f3069ef..6a0dbef 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 0c7bf41..e5d69fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,4 +12,7 @@ services: - .env environment: DEBUG: "false" + volumes: + - ./home/protected:/home/protected platform: linux/amd64 + diff --git a/home/app.py b/home/app.py index 06bae75..8005f75 100644 --- a/home/app.py +++ b/home/app.py @@ -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") @@ -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'] @@ -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) @@ -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/") +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 diff --git a/home/invite.py b/home/invite.py new file mode 100644 index 0000000..d74f4eb --- /dev/null +++ b/home/invite.py @@ -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 + ) \ No newline at end of file diff --git a/home/notion.py b/home/notion.py index 876feb5..0967311 100644 --- a/home/notion.py +++ b/home/notion.py @@ -1,13 +1,15 @@ import time import json import os -from notion_client import Client +from notion_client import AsyncClient +# from notion_client import Client from datetime import datetime +import asyncio import pandas as pd class CachedNotionClient: def __init__(self, token, cache_dir='./notion_cache', cache_ttl=3600): - self.notion = Client(auth=token) + self.token = token self.cache_dir = cache_dir self.cache_ttl = cache_ttl # Time-to-live in seconds @@ -38,11 +40,19 @@ def _write_cache(self, key, data): with open(self._get_cache_path(key), 'w') as f: json.dump(data, f) - def get_database(self, database_id): + async def get_database(self, database_id, **kwargs): cache_key = f"db_{database_id}" - + + # First, check if we have a fresh (non-expired) cache to avoid an API call entirely + cached = self._read_cache(cache_key) + if cached is not None: + return cached + + # Create a fresh AsyncClient per call so there are no stale event loop + # references across requests (Flask/asgiref gives each view its own loop) try: - data = self.notion.databases.query(database_id=database_id) + async with AsyncClient(auth=self.token) as notion: + data = await notion.databases.query(database_id=database_id, **kwargs) self._write_cache(cache_key, data) return data except Exception as e: @@ -52,6 +62,53 @@ def get_database(self, database_id): return last_cache raise e + async def get_database_all(self, database_id): + """Fetch all pages from a database, handling Notion's 100-result pagination.""" + cache_key = f"db_{database_id}_all" + + cached = self._read_cache(cache_key) + if cached is not None: + return cached + + all_results = [] + start_cursor = None + try: + async with AsyncClient(auth=self.token) as notion: + while True: + kwargs = {} + if start_cursor: + kwargs['start_cursor'] = start_cursor + page_data = await notion.databases.query(database_id=database_id, **kwargs) + all_results.extend(page_data['results']) + if not page_data.get('has_more', False): + break + start_cursor = page_data.get('next_cursor') + if not start_cursor: + break + combined = {'results': all_results} + self._write_cache(cache_key, combined) + return combined + except Exception as e: + last_cache = self._read_cache(cache_key) + if last_cache: + return last_cache + raise e + + async def update_database(self, database_id, data): + cache_key = f"db_{database_id}" + try: + async with AsyncClient(auth=self.token) as notion: + print(f"Updating database {database_id} with data: {data}") + response = await notion.pages.create(parent=dict(database_id=database_id), properties=data) + self._write_cache(cache_key, response) + return response + except Exception as e: + # If Notion API fails, return last cached version even if expired + last_cache = self._read_cache(cache_key) + if last_cache: + return last_cache + raise e + def parse_about_results(results): """ @@ -105,7 +162,7 @@ def parse_cv_results(results): df['start_date'] = pd.to_datetime(df['start_date']) df['end_date'] = pd.to_datetime(df['end_date']) - df['duration'] = round((df['end_date'] - df['start_date']).dt.days / 365, 1) # calculate duration in years + df['duration'] = round((df['end_date'] - df['start_date']).dt.days / 365, 1) # calculate duration in years # type: ignore df = df.sort_values(by='start_date', ascending=False) @@ -114,8 +171,8 @@ def parse_cv_results(results): df = pd.concat((df[df['category'] != 'Language'], languages), ignore_index=True) # convert dates to readable format - df['start_date'] = df['start_date'].dt.strftime('%b %Y') - df['end_date'] = df['end_date'].dt.strftime('%b %Y') + df['start_date'] = df['start_date'].dt.strftime('%b %Y') # type: ignore + df['end_date'] = df['end_date'].dt.strftime('%b %Y') # type: ignore df = df.fillna('') parsed_results = df.to_dict(orient='records') @@ -161,10 +218,51 @@ def parse_notes_results(results): return parsed_results +def parse_invite_wedding_results(results): + parsed_results = [] + for result in results: + parsed_result = { + "order": result['properties']['Order']['number'], + 'title': result['properties']['Name']['title'][0]['text']['content'], + 'content': [line.get("plain_text") for line in result['properties']['Text']['rich_text']] + } + parsed_results.append(parsed_result) + + + # fixed order + parsed_results = order_by(parsed_results, 'order', reverse=False) + + return parsed_results + + +def parse_album_results(results, auth_params): + parsed_results = [] + for result in results: + # check for "#hidden" checkbox + is_hidden = result['properties'].get('Hidden', {}).get('checkbox', False) + + if is_hidden: + continue + + url_path = result['properties']['URL']['url'].removeprefix("img/") + parsed_result = { + "order": result['properties']['Order']['number'], + 'title': result['properties']['Name']['title'][0]['text']['content'], + 'link': "/hidden/" + url_path + "?" + "&".join([f"{k}={v}" for k, v in auth_params.items()]) + } + parsed_results.append(parsed_result) + + # fixed order + parsed_results = order_by(parsed_results, 'order', reverse=False) + + return parsed_results + + # utils def pp(content): print(json.dumps(content, indent=2)) + def order_by(results, key, reverse=True): return sorted(results, key=lambda x: x[key], reverse=reverse) @@ -197,16 +295,63 @@ def extract_url(result, key): env = Env(".env") - sdb_client = CachedNotionClient(env.notion_sitedb_token) + # sitedb check + notion_db_client = CachedNotionClient(env.notion_sitedb_token) + + async def sitedb_check(): + about_data = await notion_db_client.get_database(env.notion_sitedb_about_id) + cv_data = await notion_db_client.get_database(env.notion_sitedb_cv_id) + notes_data = await notion_db_client.get_database(env.notion_sitedb_notes_id) + updates_data = await notion_db_client.get_database(env.notion_sitedb_update_id) - about_data = sdb_client.get_database(env.notion_sitedb_about_id) - cv_data = sdb_client.get_database(env.notion_sitedb_cv_id) - notes_data = sdb_client.get_database(env.notion_sitedb_notes_id) - updates_data = sdb_client.get_database(env.notion_sitedb_update_id) + pp(parse_about_results(about_data['results'])) + pp(parse_cv_results(cv_data['results'])) + pp(parse_notes_results(notes_data['results'])) + pp(parse_notes_results(updates_data['results'])) - pp(parse_about_results(about_data['results'])) - pp(parse_cv_results(cv_data['results'])) - pp(parse_notes_results(notes_data['results'])) - pp(parse_notes_results(updates_data['results'])) + print("SiteDB check done.") + + # asyncio.run(sitedb_check()) + + + + async def album_check(): + # album_data = await notion_db_client.get_database(env.notion_sitedb_wedding_album_id) + + # pp(parse_album_results(album_data['results'])) + + data = dict() + data['Order'] = {"number": 42} + data['Name'] = {"title": [{"text": {"content": "frank_per_auto"}}]} + data['URL'] = {"url": "img/file.txt"} + + print("Updating wedding album database with test data...") + pp(data) + response = await notion_db_client.update_database(env.notion_sitedb_wedding_album_id, data) + + print("Update response:") + pp(response) + + # asyncio.run(album_check()) + + + from pathlib import Path - print("Done.") \ No newline at end of file + async def update_album_from_local(): + album_dir = Path("home/static/img/wedding_album/") + + # get all .jpg files + jpg_files = sorted(album_dir.glob("*.JPG")) + + for i, jpg_file in enumerate(jpg_files): + data = dict( + Name={"title": [{"text": {"content": jpg_file.stem}}]}, + URL={"url": f"img/wedding_album/{jpg_file.name}"}, + Order={"number": int(jpg_file.stem.split("_")[-1])} + ) + + response = await notion_db_client.update_database(env.notion_sitedb_wedding_album_id, data) + pp(response) + + + asyncio.run(update_album_from_local()) diff --git a/home/static/css/wedding.css b/home/static/css/wedding.css new file mode 100644 index 0000000..9725890 --- /dev/null +++ b/home/static/css/wedding.css @@ -0,0 +1,280 @@ +/* BACKGROUND IMAGE */ +body{ + background-color: #ffffff; + margin: 0 auto; + max-width: 1200px; +} + +h1{ + font-family: 'Staatliches', sans-serif; + font-size: 15em; + text-align: center; + margin-top: -5%; + margin-bottom: -10%; +} +h2{ + text-align: center; + font-family: 'Staatliches', sans-serif; + font-size: 3em; + font-style: italic; + margin-bottom: -5%; +} +.container{ + background-color: rgb(37,37,37,0.0); + border-radius: 13px; + color: rgb(0, 0, 0); + font-family: 'Staatliches', sans-serif; + font-size: 2em; + font-weight: 700; + margin-left: 15px; + margin-right: 25px; + padding: 2%; + text-align: center; + z-index: 1; + position: relative; +} + + +/* FORM FORMATTING */ +.container form{ + max-width:520px; + margin:0 auto; + text-align:right; + position: relative; + z-index: 2; +} +.container form label{ + display:block; + font-weight:700; + margin-bottom:6px; + color:inherit +} +.container form input{ + width:50%; + box-sizing:border-box; + padding:12px 14px; + margin-bottom:12px; + margin-left:auto; + display:block; + border:2px solid #000; + background:#fff; + color:inherit; + font-family:inherit; + font-weight:700; + border-radius:6px; + font-size:.75em +} +.container form button[type="submit"]{ + background:#000; + color:#fff; + border:2px solid #000; + padding:10px 18px; + font-weight:700; + font-family:inherit; + border-radius:6px; + cursor:pointer +} +.container form button[type="submit"]:hover{ + opacity:.9 +} +.container form input::placeholder{ + color:#666; + font-weight:700 +} + + +/* INVITE CONTENT */ +.invite-content{ + padding: 2%; + width: 75%; + margin: 0 auto; + font-family: 'Staatliches', sans-serif; + font-weight: 900; + font-size: 2em; + text-align: center; +} + + +/* BAUHAUS SHAPES */ +.red-rectangle { + position: absolute; + top: 5%; + left: 10%; + width: 900px; + height: 275px; + background-color: #cf0000; + z-index: -1; + pointer-events: none; + will-change: transform; + transform: translateY(0); +} +.blue-square { + position: absolute; + top: 9%; + right: 12%; + width: 500px; + height: 500px; + background-color: #3939fa; + z-index: -1; + pointer-events: none; + will-change: transform; + transform: translateY(0); +} +.yellow-triangle { + position: absolute; + top: 15%; + left: 25%; + width: 0; + height: 0; + border-left: 300px solid transparent; + border-right: 300px solid transparent; + border-bottom: 500px solid #ecd801; + z-index: -1; + pointer-events: none; + will-change: transform; + transform: translateY(0); +} +.blue-circle { + position: absolute; + top: 25%; + left: 5%; + width: 650px; + height: 650px; + background-color: #3939fa; + border-radius: 50%; + z-index: -1; + pointer-events: none; + will-change: transform; + transform: translateY(0); +} +.yellow-square { + position: absolute; + top: 50%; + right: 25%; + width: 420px; + height: 420px; + background-color: #ecd801; + z-index: -1; + pointer-events: none; + will-change: transform; + transform: translateY(0); +} +.red-circle { + position: absolute; + top: 37%; + right: 11%; + width: 450px; + height: 450px; + background-color: #cf0000; + border-radius: 50%; + z-index: -1; + pointer-events: none; + will-change: transform; + transform: translateY(0); +} +.blue-rectangle { + position: absolute; + bottom: 20%; + right: 20%; + width: 700px; + height: 200px; + background-color: #3939fa; + z-index: -1; + pointer-events: none; + will-change: transform; + transform: translateY(0); +} +.red-triangle { + position: absolute; + bottom: 13%; + left: 40%; + width: 0; + height: 0; + border-left: 250px solid transparent; + border-right: 250px solid transparent; + border-bottom: 400px solid #cf0000; + z-index: -1; + pointer-events: none; + will-change: transform; + transform: translateY(0); +} + +img.invite-signature{ + border-radius: 50%; + z-index: 1; + width: 50%; + margin-top: -10%; +} + +div.album-content { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 20px; + margin-top: 20px; +} +div.photo-card { + width: 300px; + text-align: center; +} + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 24px; + margin: 32px auto 16px; +} +.pagination-btn { + display: inline-block; + background: #000; + color: #fff; + border: 2px solid #000; + padding: 10px 22px; + font-family: 'Staatliches', sans-serif; + font-size: 1em; + font-weight: 700; + border-radius: 6px; + cursor: pointer; + letter-spacing: 0.05em; + text-decoration: none; +} +.pagination-btn:hover { + background: #cf0000; + border-color: #cf0000; + color: #fff; + text-decoration: none; +} +.page-indicator { + font-family: 'Staatliches', sans-serif; + font-size: 1.2em; + font-weight: 700; + min-width: 60px; + text-align: center; +} +div.photo-card img { + width: 100%; + height: auto; + border-radius: 10px; + transition: transform 0.3s ease; +} + + + +.credits{ + font-family: sans-serif; + font-size: 0.5em; + text-align: center; + color: rgba(0, 0, 0, 0.50); +} +.credits a{ + color: rgba(0, 0, 0, 0.75); + text-decoration: none; +} + +/* LOCAL FONTS */ +@font-face { + font-family: "Staatliches"; + src: url('../fonts/Staatliches-Regular.ttf'); +} + diff --git a/home/static/fonts/Staatliches-Regular.ttf b/home/static/fonts/Staatliches-Regular.ttf new file mode 100644 index 0000000..5036a93 Binary files /dev/null and b/home/static/fonts/Staatliches-Regular.ttf differ diff --git a/home/static/img/fnp.ico b/home/static/img/fnp.ico new file mode 100644 index 0000000..365d069 Binary files /dev/null and b/home/static/img/fnp.ico differ diff --git a/home/static/js/wedding-parallax.js b/home/static/js/wedding-parallax.js new file mode 100644 index 0000000..c013dbc --- /dev/null +++ b/home/static/js/wedding-parallax.js @@ -0,0 +1,44 @@ +(function(){ + // Parallax for background shapes. Assigns different speeds per shape. + const config = [ + {sel: '.red-rectangle', speed: 0.5}, + {sel: '.blue-square', speed: 0.3}, + {sel: '.yellow-triangle', speed: 0.6}, + {sel: '.blue-circle', speed: 0.4}, + {sel: '.red-circle', speed: 0.4}, + {sel: '.yellow-square', speed: 0.3}, + {sel: '.blue-rectangle', speed: 0.1}, + {sel: '.red-triangle', speed: 0.05}, + + ]; + + const layers = config.map(c => ({el: document.querySelector(c.sel), speed: c.speed})).filter(l => l.el); + if (!layers.length) return; + + let ticking = false; + let lastScrollY = -1; + + function update() { + const scrollY = window.scrollY || window.pageYOffset; + if (scrollY !== lastScrollY) { + lastScrollY = scrollY; + layers.forEach(l => { + // move down proportionally to scroll, slower than page for depth + const offset = Math.round(scrollY * l.speed); + l.el.style.transform = `translate3d(0, ${offset}px, 0)`; + }); + } + ticking = false; + } + + function onScroll() { + if (!ticking) { + window.requestAnimationFrame(update); + ticking = true; + } + } + + window.addEventListener('scroll', onScroll, {passive: true}); + // also run once to set initial positions + update(); +})(); diff --git a/home/templates/invite_wedding.html b/home/templates/invite_wedding.html new file mode 100644 index 0000000..1763663 --- /dev/null +++ b/home/templates/invite_wedding.html @@ -0,0 +1,42 @@ + + + + {% block title %}Frank & Per's Wedding{% endblock %} + + + {% include "includes/analytics.html" %} + + + +
+
+
+
+
+
+
+
+
+ +

F+P

+ +
+ {% for item in invite_wedding %} +

{{ item.title|safe }}

+ {% for line in item.content %} +

{{ line|safe }}

+ {% endfor %} +

+ {% endfor %} +
+ + frank_og_per +
+

photo by Tommy Ellingsen

+

invitation design by Per

+
+ +
+ + + diff --git a/home/templates/invite_wedding_empty.html b/home/templates/invite_wedding_empty.html new file mode 100644 index 0000000..b128b3b --- /dev/null +++ b/home/templates/invite_wedding_empty.html @@ -0,0 +1,48 @@ + + + + {% block title %}Frank & Per's Wedding{% endblock %} + + + {% include "includes/analytics.html" %} + + + +
+
+
+
+ +


+ unlock your invitation by answering the following questions: + +
+

+ + +

+ + + +

+ + + +

+ + + +

+ + + + +
+ + +
+ + + + + diff --git a/home/templates/wedding_album.html b/home/templates/wedding_album.html new file mode 100644 index 0000000..c68e455 --- /dev/null +++ b/home/templates/wedding_album.html @@ -0,0 +1,49 @@ + + + + {% block title %}Frank & Per's Wedding{% endblock %} + + + {% include "includes/analytics.html" %} + + + +
+
+
+
+ + +

F+P

+ +
+ {% for photo in album %} +
+ + {{ photo.title|safe }} + +
+
+ {% endfor %} +
+ + + +
+

photos by Tommy Ellingsen

+
+ +
+ + + diff --git a/requirements.txt b/requirements.txt index cc1f4ad..deee106 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,12 @@ flask==3.1.0 +flask[async]==3.1.0 gunicorn==20.0.4 matplotlib==3.10.1 nbformat==5.10.4 notion-client==2.3.0 spotipy==2.25.1 pandas -Pillow==11.1.0 +Pillow>=11.1.0 pytest==8.3.4 python-dotenv==1.0.1 pyyaml==6.0.2