Compare commits

..

4 commits

Author SHA1 Message Date
Piotr Gaczkowski
7553028f2d feat: Now more timezone aware than ever 2026-05-03 18:39:04 +02:00
Piotr Gaczkowski
31d7c67783 feat: Implement code review comments 2026-05-03 11:06:52 +02:00
Piotr Gaczkowski
88765f396e feat: Add Web UI for calendar and card generation 2026-05-02 12:50:22 +02:00
Piotr Gaczkowski
64a45a8641 feat: Make good art (with Typst) 2026-04-27 22:20:49 +02:00
17 changed files with 27789 additions and 112 deletions

View file

@ -18,9 +18,15 @@
sync.allExtras = true; sync.allExtras = true;
}; };
}; };
typst = {
enable = true;
};
}; };
packages = [ packages = [
pkgs.google-fonts
pkgs.hanken-grotesk
pkgs.typstyle
pkgs.uv pkgs.uv
]; ];

View file

@ -18,8 +18,10 @@ dependencies = [
"cssselect==1.3.0", "cssselect==1.3.0",
"cssutils==2.11.1", "cssutils==2.11.1",
"discord.py==2.7.1", "discord.py==2.7.1",
"fastapi[standard]>=0.136.1",
"html5lib==1.1", "html5lib==1.1",
"idna==3.10", "idna==3.10",
"immichpy>=4.1.3",
"Jinja2==3.1.6", "Jinja2==3.1.6",
"lxml==6.0.0", "lxml==6.0.0",
"MarkupSafe==3.0.2", "MarkupSafe==3.0.2",
@ -27,10 +29,12 @@ dependencies = [
"premailer==3.10.0", "premailer==3.10.0",
"python-dateutil==2.9.0.post0", "python-dateutil==2.9.0.post0",
"python-dotenv==1.1.1", "python-dotenv==1.1.1",
"pytz==2026.1.post1",
"requests==2.32.4", "requests==2.32.4",
"six==1.17.0", "six==1.17.0",
"soupsieve==2.7", "soupsieve==2.7",
"typing_extensions==4.14.0", "typing_extensions==4.14.0",
"typst>=0.14.8",
"urllib3==2.5.0", "urllib3==2.5.0",
"webencodings==0.5.1", "webencodings==0.5.1",
] ]

View file

74
src/kronos-ui/main.py Normal file
View file

@ -0,0 +1,74 @@
from datetime import datetime
import json
import os
import shutil
from fastapi import FastAPI, File, UploadFile, Form
from fastapi.responses import HTMLResponse, Response
from fastapi.staticfiles import StaticFiles
import typst
from kronos.mobilizon import get_raw_events
app = FastAPI()
app.mount("/static", StaticFiles(directory="src/kronos-ui/static/"), name="static")
@app.get("/", response_class=HTMLResponse)
async def root():
with open("src/kronos-ui/static/index.html", "rb") as f:
index = f.read()
return index
@app.get("/calendar", responses={200: {"content": {"image/png": {}}}}, response_class=Response)
async def calendar():
get_raw_events()
png_bytes = typst.compile("src/typst/calendar.typ", format="png")
return Response(content=png_bytes, media_type="image/png")
@app.post("/event_card", responses={200: {"content": {"image/png": {}}}}, response_class=Response)
async def event_card( # noqa: PLR0913
title: str = Form(""),
description: str = Form(""),
begins=Form(...),
ends=Form(...),
speaker: str = Form(""),
picture: UploadFile = File(...),
):
pic_path = os.path.join("src", "typst", "assets", "tmp", f"{datetime.now()}.jpg")
fp = open(pic_path, "wb")
if picture and picture.size > 0:
shutil.copyfileobj(picture.file, fp)
else:
with open(os.path.join("src", "typst", "assets", "photo.jpg"), "rb") as placeholder:
shutil.copyfileobj(placeholder, fp)
# TODO: pack this in a method
events = [
{
"title": title,
"beginsOn": begins,
"endsOn": ends,
"description": description,
"physicalAddress": None,
"picture": os.path.relpath(pic_path, start="src/typst"),
"url": None,
"txt_description": description,
"txt_date": f"{begins.split('T')[0]} {begins.split('T')[1]}-{ends.split('T')[1]}",
# "txt_date": "2026-04-29 18:00-19:00",
"speaker": speaker,
}
]
sys_inputs = {"events": json.dumps(events)}
print(sys_inputs)
# FIXME: remove hardcoded paths
png_bytes = typst.compile("src/typst/event.typ", format="png", sys_inputs=sys_inputs)
fp.close()
return Response(content=png_bytes, media_type="image/png")

3
src/kronos-ui/setzer.py Normal file
View file

@ -0,0 +1,3 @@
import typst
typst.compile("src/typst/calendar.typ", output="calendar.pdf")

View file

@ -0,0 +1,164 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="description" content="Kronos Generator" />
<title>Kronos UI -- Generator Genialnych Grafik</title>
<link rel="shortcut icon" href="static/favicon.ico" />
<!-- Font Awesome CSS -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<!-- Bootstrap CSS -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity=""
/>
<link rel="stylesheet" href="static/css/style.css" />
<!-- DataTables CSS -->
<link
rel="stylesheet"
href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css"
/>
<style>
* {
margin: 0;
padding: 0;
}
body {
background: white;
}
.container {
border: 1px solid grey;
margin: 1rem;
}
[data-tab-info] {
display: none;
}
.active[data-tab-info] {
display: block;
}
.tab-content {
margin-top: 1rem;
padding-left: 1rem;
font-size: 20px;
font-family: sans-serif;
font-weight: bold;
color: rgb(0, 0, 0);
}
.tabs {
border-bottom: 1px solid grey;
background-color: rgb(16, 153, 9);
font-size: 25px;
color: rgb(0, 0, 0);
display: flex;
margin: 0;
}
.tabs span {
background: rgb(16, 153, 9);
padding: 10px;
border: 1px solid rgb(255, 255, 255);
}
.tabs span:hover {
background: rgb(55, 219, 46);
cursor: pointer;
color: black;
}
</style>
</head>
<body>
<div id="site-body" class="container-fluid">
<div class="row flex-nonwrap">
<div class="sidenav"></div>
<div class="main">
<h1>Kronos</h1>
<div class="tabs">
<span data-tab-value="#tab_1">Kalendarz</span>
<span data-tab-value="#tab_2">Karta</span>
</div>
<div class="tab-content">
<div class="tabs__tab active" id="tab_1" data-tab-info>
<h2>Kalendarz</h2>
<form action="/calendar">
<label for="calendar_begins">Początek:</label>
<input type="date" id="calendar_begins" name="begins"><br>
<input type="submit" value="Make me a calendar!">
</form>
<div>
<img src="/calendar" width="80%">
</div>
</div>
<div class="tabs__tab" id="tab_2" data-tab-info>
<h2>Karta</h2>
<form action="/event_card" method="post" enctype="multipart/form-data">
<label for="title">Tytuł:</label><br>
<input type="text" id="title" name="title"><br>
<label for="description">Opis:</label><br>
<input type="text" id="description" name="description"><br>
<label for="begins">Początek:</label>
<input type="datetime-local" id="card_begins" name="begins"><br>
<label for="ends">Koniec:</label>
<input type="datetime-local" id="card_ends" name="ends"><br>
<label for="speaker">Występuje:</label><br>
<input type="text" id="speaker" name="speaker"><br>
<label for="picture">Fotka:</label><br>
<input type="file" id="picture" name="picture" accept="image/png, image/jpeg"><br>
<input type="submit" value="Make it so!">
</form>
</div>
</div>
</div>
</div>
</div>
<footer>
<a href="https://forge.hs3.pl/leming/kronos"
>Źródełka</a
>
</footer>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<script type="text/javascript">
// function to get each tab details
const tabs = document.querySelectorAll('[data-tab-value]')
const tabInfos = document.querySelectorAll('[data-tab-info]')
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const target = document
.querySelector(tab.dataset.tabValue);
tabInfos.forEach(tabInfo => {
tabInfo.classList.remove('active')
})
target.classList.add('active');
})
})
const now = new Date();
const dateValue = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const calendarStartDate = document.getElementById('calendar_begins');
calendarStartDate.value = dateValue;
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
const cardStartDate = document.getElementById('card_begins');
cardStartDate.value = now.toISOString().slice(0,16);
const cardEndsDate = document.getElementById('card_ends');
cardEndsDate.value = now.toISOString().slice(0,16);
</script>
</body>
</html>

View file

@ -4,7 +4,6 @@ import sys
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from argparse import ArgumentParser from argparse import ArgumentParser
import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from dotenv import load_dotenv from dotenv import load_dotenv
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
@ -12,13 +11,13 @@ from premailer import transform
from .discordbot import KronosBot, intents from .discordbot import KronosBot, intents
from .mobilizon import get_raw_events
MOBILIZON_API_URL = "https://events.hs3.pl/api"
QUERY_LIMIT = 100
TIME_WINDOW_DAYS = 365
TRUNCATE_AFTER_CHARACTERS = 300 TRUNCATE_AFTER_CHARACTERS = 300
TEMPLATE_FILENAME = "templates/newsletter_template.html" TEMPLATES = {
OUTPUT_FILENAME = "newsletter_events.html" "templates/newsletter_template.html": "newsletter_events.html",
}
def sanitize_html(raw_html: str) -> str: def sanitize_html(raw_html: str) -> str:
@ -29,69 +28,8 @@ def sanitize_html(raw_html: str) -> str:
return cleaned return cleaned
def get_time_window(days: int = 365):
now_utc = datetime.datetime.now(datetime.timezone.utc)
ends_utc = now_utc + datetime.timedelta(days=days)
begins_iso = now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
ends_iso = ends_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
return begins_iso, ends_iso
def build_graphql_query():
return """
query SearchEventsInWindow($beginsOn: DateTime, $endsOn: DateTime, $limit: Int) {
searchEvents(beginsOn: $beginsOn, endsOn: $endsOn, limit: $limit) {
total
elements {
__typename
... on Event {
id
title
description
beginsOn
picture {
url
}
url
physicalAddress {
description
locality
}
}
}
}
}
"""
def fetch_events(begins_on: str, ends_on: str, limit: int = QUERY_LIMIT):
query = build_graphql_query()
variables = {"beginsOn": begins_on, "endsOn": ends_on, "limit": limit}
payload = {"query": query, "variables": variables}
headers = {"Content-Type": "application/json"}
resp = requests.post(MOBILIZON_API_URL, json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
if "errors" in data:
raise RuntimeError(f"GraphQL error: {data['errors']}")
events = []
for elem in data["data"]["searchEvents"]["elements"]:
if elem.get("__typename") == "Event":
begins_iso = elem.get("beginsOn")
if begins_iso:
dt = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
dt_begins = datetime.datetime.fromisoformat(begins_on.replace("Z", "+00:00"))
dt_ends = datetime.datetime.fromisoformat(ends_on.replace("Z", "+00:00"))
if dt_begins <= dt < dt_ends:
events.append(elem)
return events
def prepare_events_for_template(raw_events: list) -> list: def prepare_events_for_template(raw_events: list) -> list:
jours_semaine = [ days_week = [
"poniedziałek", "poniedziałek",
"wtorek", "wtorek",
"środa", "środa",
@ -100,7 +38,7 @@ def prepare_events_for_template(raw_events: list) -> list:
"sobota", "sobota",
"niedziela", "niedziela",
] ]
mois_annee = [ month_year = [
"", "",
"stycznia", "stycznia",
"lutego", "lutego",
@ -132,6 +70,7 @@ def prepare_events_for_template(raw_events: list) -> list:
begins_iso = ev.get("beginsOn") begins_iso = ev.get("beginsOn")
picture = ev.get("picture") picture = ev.get("picture")
picture_url = picture.get("url") if picture else None picture_url = picture.get("url") if picture else None
picture_filename = ev.get("picture_filename")
link = ev.get("url") or "" link = ev.get("url") or ""
if picture_url and "?" in picture_url: if picture_url and "?" in picture_url:
@ -146,15 +85,15 @@ def prepare_events_for_template(raw_events: list) -> list:
location = "" location = ""
dt_utc = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00")) dt_utc = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
dt_paris = dt_utc.astimezone(ZoneInfo("Europe/Paris")) dt_warsaw = dt_utc.astimezone(ZoneInfo("Europe/Warsaw"))
jour_nom = jours_semaine[dt_paris.weekday()] day_name = days_week[dt_warsaw.weekday()]
jour_num = dt_paris.day day_num = dt_warsaw.day
mois_nom = mois_annee[dt_paris.month] month_name = month_year[dt_warsaw.month]
annee = dt_paris.year year = dt_warsaw.year
heure = dt_paris.hour hour = dt_warsaw.hour
minute = dt_paris.minute minute = dt_warsaw.minute
minute_str = f"{minute:02d}" minute_str = f"{minute:02d}"
full_date = f"{jour_nom}, {jour_num} {mois_nom} {annee} @ {heure}:{minute_str}" full_date = f"{day_name}, {day_num} {month_name} {year} @ {hour}:{minute_str}"
prepared.append( prepared.append(
{ {
@ -162,6 +101,7 @@ def prepare_events_for_template(raw_events: list) -> list:
"description": truncated, "description": truncated,
"full_date": full_date, "full_date": full_date,
"picture_url": picture_url, "picture_url": picture_url,
"picture_filename": picture_filename,
"location": location, "location": location,
"link": link, "link": link,
"start_time": dt_utc, "start_time": dt_utc,
@ -199,45 +139,27 @@ def _main():
_args = parser.parse_args() _args = parser.parse_args()
try: try:
begins_on, ends_on = get_time_window(days=TIME_WINDOW_DAYS) raw_events = get_raw_events()
print(
f"Fetching events between {begins_on} and {ends_on}...",
file=sys.stderr,
)
raw_events = list(
filter(
lambda x: "https://events.hs3.pl/" in x.get("url", ""),
fetch_events(begins_on, ends_on),
)
)
if not raw_events:
print("No events found in the requested period.", file=sys.stderr)
sys.exit(0)
for ev in raw_events:
print(f"{ev.get('title', 'Untitled')}{ev.get('beginsOn', 'Unknown date')}")
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
events = prepare_events_for_template(raw_events) events = prepare_events_for_template(raw_events)
os.makedirs("dist", exist_ok=True) for template_filename, output_filename in TEMPLATES.items():
output_path = os.path.join(os.getcwd(), "dist", OUTPUT_FILENAME) output = render_newsletter(
html_output = render_newsletter( events=events, template_dir=os.getcwd(), template_name=template_filename
events=events, template_dir=os.getcwd(), template_name=TEMPLATE_FILENAME )
)
with open(output_path, "w", encoding="utf-8") as f: os.makedirs("dist", exist_ok=True)
f.write(html_output) output_path = os.path.join(os.getcwd(), "dist", output_filename)
print(f"File '{output_path}' generated successfully.", file=sys.stderr) with open(output_path, "w", encoding="utf-8") as f:
f.write(output)
print(f"File '{output_path}' generated successfully.", file=sys.stderr)
inlined_output_path = os.path.join(os.getcwd(), "dist", "newsletter_events_inlined.html") if output_filename.endswith(".html"):
inline_css(output_path, inlined_output_path) inlined_output_path = os.path.join(os.getcwd(), "dist", f"inlined_{output_filename}")
print( inline_css(output_path, inlined_output_path)
f"File '{inlined_output_path}' (inline CSS) generated.", print(
file=sys.stderr, f"File '{inlined_output_path}' (inline CSS) generated.",
) file=sys.stderr,
)
discord_token = os.getenv("DISCORD_TOKEN") discord_token = os.getenv("DISCORD_TOKEN")
client = KronosBot(intents=intents) client = KronosBot(intents=intents)

143
src/kronos/mobilizon.py Normal file
View file

@ -0,0 +1,143 @@
import datetime
import json
import os
import sys
import requests
from bs4 import BeautifulSoup
from pytz import timezone
MOBILIZON_API_URL = "https://events.hs3.pl/api"
QUERY_LIMIT = 100
TIME_WINDOW_DAYS = 365
def get_time_window(start_utc=datetime.datetime.now(datetime.timezone.utc), days: int = 365):
now_utc = start_utc
ends_utc = now_utc + datetime.timedelta(days=days)
begins_iso = now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
ends_iso = ends_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
return begins_iso, ends_iso
def build_graphql_query():
return """
query SearchEventsInWindow($beginsOn: DateTime, $endsOn: DateTime, $limit: Int) {
searchEvents(beginsOn: $beginsOn, endsOn: $endsOn, limit: $limit) {
total
elements {
__typename
... on Event {
id
title
description
beginsOn
endsOn
picture {
url
}
url
physicalAddress {
description
locality
}
}
}
}
}
"""
def fetch_events(begins_on: str, ends_on: str, limit: int = QUERY_LIMIT):
query = build_graphql_query()
variables = {"beginsOn": begins_on, "endsOn": ends_on, "limit": limit}
payload = {"query": query, "variables": variables}
headers = {"Content-Type": "application/json"}
resp = requests.post(MOBILIZON_API_URL, json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
if "errors" in data:
raise RuntimeError(f"GraphQL error: {data['errors']}")
events = []
for elem in data["data"]["searchEvents"]["elements"]:
if elem.get("__typename") == "Event":
begins_iso = elem.get("beginsOn")
if begins_iso:
dt = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
dt_begins = datetime.datetime.fromisoformat(begins_on.replace("Z", "+00:00"))
dt_ends = datetime.datetime.fromisoformat(ends_on.replace("Z", "+00:00"))
if dt_begins <= dt < dt_ends:
events.append(elem)
return events
def save_json_content(events):
json_dirname = "src/typst/content"
os.makedirs(json_dirname, exist_ok=True)
with open(os.path.join(json_dirname, "events.json"), "w") as f:
json.dump(sorted(events, key=lambda event: event["beginsOn"]), f)
def extract_picture(event):
picture = event.get("picture")
picture_url = picture.get("url") if picture else None
picture_filename = None
if picture_url:
if "?" in picture_url:
picture_url = picture_url.split("?", 1)[0]
picture_dirname = "src/typst/assets/tmp"
picture_filename = os.path.join(picture_dirname, picture_url.split("/")[-1])
event["picture_filename"] = picture_filename
response = requests.get(picture_url)
os.makedirs(picture_dirname, exist_ok=True)
with open(picture_filename, "wb") as picture_file:
picture_file.write(response.content)
def get_raw_events():
begins_on, ends_on = get_time_window(days=TIME_WINDOW_DAYS)
print(
f"Fetching events between {begins_on} and {ends_on}...",
file=sys.stderr,
)
raw_events = list(
filter(
lambda x: "https://events.hs3.pl/" in x.get("url", ""),
fetch_events(begins_on, ends_on),
)
)
if not raw_events:
print("No events found in the requested period.", file=sys.stderr)
sys.exit(0)
for ev in raw_events:
print(f"{ev.get('title', 'Untitled')}{ev.get('beginsOn', 'Unknown date')}")
ev["txt_description"] = BeautifulSoup(ev["description"], "html.parser").get_text()
local_tz = timezone("Europe/Warsaw")
begins_date = datetime.datetime.fromisoformat(ev["beginsOn"]).astimezone(local_tz)
ends_date = datetime.datetime.fromisoformat(ev["endsOn"]).astimezone(local_tz)
begins = (begins_date.strftime("%Y-%m-%d"), begins_date.strftime("%H:%M"))
ends = (ends_date.strftime("%Y-%m-%d"), ends_date.strftime("%H:%M"))
txt_date = f"{begins[0]} {begins[1]}-"
if begins[0] != ends[0]:
txt_date += f"{ends[0]}"
txt_date += f"{ends[1]}"
ev["txt_date"] = txt_date
extract_picture(ev)
save_json_content(raw_events)
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
return raw_events

2
src/typst/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
assets/tmp/*
*.pdf

25941
src/typst/assets/calendar.svg Normal file

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 1.4 MiB

1244
src/typst/assets/event.svg Normal file

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

BIN
src/typst/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
src/typst/assets/photo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

88
src/typst/calendar.typ Normal file
View file

@ -0,0 +1,88 @@
#import "@preview/cades:0.3.1": qr-code
#set page(
paper: "a4",
background: context {
place(center + horizon, {
image("assets/calendar.svg")
})
},
margin: (top: .5cm, bottom: .5cm, x: 0cm),
)
#place(
top + left,
dx: 8mm,
dy: -14mm,
image("assets/hs3_logo_hires.png", height: 58mm, width: 58mm),
)
#place(
top + left,
dx: 13mm,
dy: 145mm,
qr-code("https://hs3.pl", width: 32mm),
)
#let add_event(number, title, description, date, address) = {
let ex = 85mm + calc.rem-euclid(number, 2) * 67mm
let ey = 46mm + calc.div-euclid(number, 2) * 76mm
set text(
fill: white,
font: "Rajdhani",
)
place(
top + left,
dx: ex,
dy: ey,
block(
width: 32mm,
height: 48mm,
[
#block(
height: 14mm,
text(
weight: "bold",
title,
),
)
#move(dy: 3mm, block(
height: 8mm,
clip: true,
text(
weight: "bold",
size: 10pt,
date,
),
))
#move(dy: 4mm, block(
height: 12mm,
clip: true,
text(size: 10pt, address),
))
],
),
)
}
#let events = json("content/events.json")
#let index = 0
#for event in events {
add_event(
index,
event.title,
event.txt_description,
event.txt_date,
"Hackerspace Trójmiasto, al. Wojska Polskiego 41, Gdańsk",
)
index = index + 1
if (index >= 6) {
break
}
}

View file

86
src/typst/event.typ Normal file
View file

@ -0,0 +1,86 @@
#import "@preview/bulb:0.1.0": dither
#set page(
width: 297mm,
height: 169mm,
background: context {
place(center + horizon, {
image("assets/event.svg")
})
},
margin: (top: 0cm, bottom: .5cm, x: .5cm),
)
#let make_event_card(title, description, date, address, picture, name) = {
move(
dx: 148mm,
dy: 23mm,
image(
dither(
read(picture, encoding: none),
mode: "bw",
method: "bayer8x8",
size: 360,
),
),
)
place(
top + left,
dx: 94mm,
dy: -4mm,
image("assets/hs3_logo_hires.png", height: 38mm, width: 38mm),
)
set text(font: "Hanken Grotesk", size: 14pt)
place(
top + left,
dx: 12mm,
dy: 30mm,
block(
width: 118mm,
height: 130mm,
grid(
rows: (2fr, 6fr, 0.75fr, 0.5fr),
text(size: 28pt, weight: "bold", title),
text(size: 10pt, description),
text(
size: 24pt,
weight: "bold",
grid(
columns: (1fr, 1fr),
date.split(" ").at(0), align(right)[#date.split(" ").at(1)],
),
),
address,
),
),
)
align(right + bottom)[#text(size: 14pt, weight: "bold", name)]
}
#let events = none
#if sys.inputs.len() < 1 {
events = json("content/events.json")
} else {
events = json(bytes(sys.inputs.events))
}
#let index = 0
#for event in events {
make_event_card(
event.title,
event.txt_description,
event.txt_date,
"Gdańsk, al. Wojska Polskiego 41",
event.picture,
event.speaker,
)
index = index + 1
if index < events.len() {
pagebreak()
}
}