Compare commits

...

30 Commits

Author SHA1 Message Date
e82f7224c5 Report errors to Glitchtip 2025-02-26 01:10:52 +01:00
cdeca9498c Handle HTTP errors gracefully 2025-02-23 17:18:56 +01:00
2d90b57235 fix docker image build by using python 3.13 base image
I'm not sure why this fixes the image build, but I found others on the internet suggesting that using "the current" version image fixes this kind of issue and it indeed fixed the issue and I was able to build the image with this adjustment.
2025-02-07 23:27:24 +01:00
53cb75f1d5 Add configurable legend to footer 2025-02-06 01:01:45 +01:00
54222eb8cc Support overwriting details for all events
Useful for calendars which contain events of the same kind and the same location only but need some adjustments to improve the usability of the calendar.
2025-02-05 23:40:05 +01:00
af33203932 Add support for feeds from reparatur-initiativen.de 2025-02-05 23:07:48 +01:00
93b0a314d4 Make app easier to run in debug mode 2025-02-05 23:07:15 +01:00
d8fa4e1dcb Disable poetry's package-mode 2025-02-03 23:48:51 +01:00
e8b8150dd5 Update dependencies 2023-09-18 16:15:21 +02:00
527d9642bc Fix date/time localization 2023-04-20 03:01:24 +02:00
f2b25ce868 Mount configuration read-only 2023-04-20 03:01:24 +02:00
691f9181e1 Fix config file caching 2023-02-19 03:20:08 +01:00
b432503ac9 Update Docker config 2023-02-18 13:32:06 +01:00
00b74e8782 Move towards a more object-oriented API 2023-02-18 04:21:28 +01:00
70a069fe95 Support multiple calendars
This implied switching to a different caching approach.
2023-02-18 04:21:28 +01:00
5d58ba89c4 Serve CSS dependencies from local webserver
Privacy improvement.
2022-11-22 02:20:48 +01:00
eab91b7272 Better highlight entries that pop open 2022-11-21 14:03:44 +01:00
276110b2a2 Fix rendering when max_days is not specified 2022-11-21 14:00:52 +01:00
bb2f840cec Add sidebar demo endpoint 2022-11-21 12:02:16 +01:00
42153f2e27 Add max_days query parameter 2022-11-21 11:44:38 +01:00
3dc8a7f232 Fix template context 2022-11-21 11:44:10 +01:00
9b8da8c2b6 Improve subscription link 2022-11-21 04:08:37 +01:00
cba66c794e Improve sidebar embed look and feel 2022-11-21 04:08:37 +01:00
05f985d611 Include static and template files in packages 2022-11-21 03:15:34 +01:00
78eeac7ab5 Ignore dist files 2022-11-21 03:15:34 +01:00
df427c2c52 Use up to date poetry installation command 2022-11-21 03:15:34 +01:00
98f9802a8e Update dependencies 2022-11-21 03:15:34 +01:00
56f529f28d Update docker file 2022-11-21 03:15:34 +01:00
4af54f3287 Restructure project 2022-11-21 03:15:34 +01:00
8209049486 Fix isort config 2022-11-21 02:55:54 +01:00
25 changed files with 2390 additions and 1390 deletions

4
.gitignore vendored
View File

@ -3,3 +3,7 @@ __pycache__/
.idea/
*.*swp*
docker-compose.yml
dist/
node_modules/
yarn*.log
*.yml

View File

@ -1,21 +1,23 @@
FROM python:3.8-alpine
FROM python:3.13-alpine
RUN adduser -S fabcal
RUN apk add --no-cache build-base libuv-dev libffi-dev && \
pip3 install poetry
RUN apk add --no-cache build-base libuv-dev libffi-dev yarn && \
pip3 install -U poetry
RUN install -d -o fabcal /app
USER fabcal
COPY poetry.lock pyproject.toml /app/
COPY poetry.lock pyproject.toml package.json yarn.lock /app/
WORKDIR /app/
RUN pip install -U poetry && \
poetry install --no-dev
RUN poetry install --only main && \
yarn install
COPY main.py /app/
COPY fabcal /app/fabcal/
COPY static/ /app/static/
COPY templates/ /app/templates/
ENTRYPOINT ["poetry", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000", "--use-colors"]
ENTRYPOINT ["poetry", "run", "uvicorn", "fabcal.app:app", "--host", "0.0.0.0", "--port", "5000", "--use-colors"]

7
config.yml.example Normal file
View File

@ -0,0 +1,7 @@
calendars:
- name: "Events"
url: "https://example.org/events.ics"
- name: "More events"
url: "https://example.com/more-events.ics"
default_color: "#aabbcc"

View File

@ -7,7 +7,11 @@ services:
ports:
- 5000:5000
environment:
- CALENDAR_URL=webcal://some.host/somecal
- FABCAL_CACHE_EXPIRE=120
- LANG=de
- LC_ALL=de_DE.UTF-8
volumes:
- ./config.yml:/app/config.yml:ro
# use the line below when running this behind reverse proxy (only if it sets the proxy headers, though)
#command: --proxy-headers --forwarded-allow-ips="*"

0
fabcal/__init__.py Normal file
View File

51
fabcal/app.py Normal file
View File

@ -0,0 +1,51 @@
import locale
import os
import sentry_sdk
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fabcal.routers import api_v1, frontend
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),
# Add data like request headers and IP for users, if applicable;
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
send_default_pii=True,
# Set traces_sample_rate to 1.0 to capture 100%
# of transactions for tracing.
traces_sample_rate=1.0,
# Set profiles_sample_rate to 1.0 to profile 100%
# of sampled transactions.
# We recommend adjusting this value in production.
profiles_sample_rate=1.0,
)
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
for framework in ["normalize.css", "milligram", "@fortawesome/fontawesome-free"]:
app.mount(f"/assets/{framework}", StaticFiles(directory=f"node_modules/{framework}"), name=framework)
app.include_router(api_v1.router, prefix="/api/v1")
app.include_router(frontend.router, prefix="")
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://fablab-altmuehlfranken.de",
"https://www.fablab-altmuehlfranken.de",
],
)
locale.setlocale(locale.LC_TIME, locale.getlocale())
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, debug=True)

234
fabcal/calendar_client.py Normal file
View File

@ -0,0 +1,234 @@
import asyncio
import logging
import os
from collections import OrderedDict
from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, NamedTuple
import aiohttp
import pytz
import recurring_ical_events
import yaml
from asyncache import cached
from cachetools import TTLCache, FIFOCache
from icalendar import Calendar
from fabcal.models import CalendarEvent
class ConfiguredCalendar(NamedTuple):
name: str
url: str
default_color: str = None
overwrite_summary: str = None
overwrite_description: str = None
overwrite_location: str = None
class CalendarClient:
def __init__(self, url: str):
self.url = url
async def get(self, session):
async with session.get(self.url) as response:
response.raise_for_status()
assert response.content_type.lower() == "text/calendar"
return await response.text()
# this will be cached permanently, i.e., the server process needs to be restarted to apply config changes
# note that caching doesn't work at all with iterators (for obvious reasons)
@cached(FIFOCache(1))
def read_calendars_from_config_file() -> List[ConfiguredCalendar]:
rv = []
with open("config.yml") as f:
data = yaml.safe_load(f)
for calendar in data["calendars"]:
rv.append(ConfiguredCalendar(**calendar))
return rv
class CombinedCalendarClient:
def __init__(self, configured_calendars: List[ConfiguredCalendar]):
self.configured_calendars = configured_calendars
async def fetch_calendars(self) -> Dict[ConfiguredCalendar, str]:
async with aiohttp.ClientSession(headers={
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
}) as session:
# need to make sure we return the calendar and the gathered values together
async def coro(calendar):
print("argh", calendar)
return calendar, await CalendarClient(calendar.url).get(session)
rv = {}
for coro in asyncio.as_completed([coro(calendar) for calendar in self.configured_calendars]):
try:
cal, response = await coro
rv[cal] = response
except aiohttp.client_exceptions.ClientResponseError:
logging.exception("Failed to fetch data for calendar, skipping")
return rv
@staticmethod
def combine_calendars(data: Dict[ConfiguredCalendar, str]) -> Calendar:
combined_calendar = Calendar()
combined_calendar.add("prodid", "-//FabCal//NONSGML//EN")
combined_calendar.add("version", "2.0")
combined_calendar.add("x-wr-calname", "FabLab Altmühlfranken e.V.")
# TODO: normalize timezones of calendar events
for configured_calendar, ical_str in data.items():
cal = Calendar.from_ical(ical_str)
# check for a calendar color (e.g., Nextcloud has such a feature)
# events that don't have a color assigned will be assigned this color unless a color was configured
default_color = configured_calendar.default_color
if not default_color:
try:
# note: for some reason, getattr doesn't work
default_color = cal["x-apple-calendar-color"]
except KeyError:
default_color = None
# if the user wishes to overwrite some data on every item in a calendar, we should do so
overwrite_summary = configured_calendar.overwrite_summary
overwrite_description = configured_calendar.overwrite_description
overwrite_location = configured_calendar.overwrite_location
# we don't copy anything but events from the
for event in cal.walk("VEVENT"):
# if no color has been configured in the event, we
if "color" not in event:
event["color"] = default_color
if overwrite_summary:
event["SUMMARY"] = overwrite_summary
if overwrite_description:
event["DESCRIPTION"] = overwrite_description
if overwrite_location:
event["LOCATION"] = overwrite_location
combined_calendar.add_component(event)
return combined_calendar
async def fetch_and_combine_calendars(self) -> Calendar:
return self.combine_calendars(await self.fetch_calendars())
@cached(TTLCache(maxsize=100, ttl=int(os.environ.get("FABCAL_CACHE_EXPIRE", 120))))
async def get_data() -> Calendar:
client = CombinedCalendarClient(read_calendars_from_config_file())
combined_calendar = await client.fetch_and_combine_calendars()
return combined_calendar
def get_tzinfo():
return timezone(timedelta(hours=1))
def get_events_from_calendar(cal: Calendar) -> List[CalendarEvent]:
"""
Generate list of events from calendar vevents.
Expands recurring events for +- one year.
Note that there is no validation, e.g., checking for values required (by other code in this application).
:param cal: calendar to fetch events from
:return: events
"""
now = datetime.now()
td = timedelta(days=365)
past_year = now - td
next_year = now + td
events = []
for vevent in recurring_ical_events.of(cal).between(past_year, next_year):
# I'm pessimistic here. Prove me wrong!
all_day_event = False
start = vevent.get("DTSTART", None)
if start is not None:
start = start.dt
if not isinstance(start, datetime):
all_day_event = True
start = datetime.combine(start, datetime.min.time(), tzinfo=get_tzinfo())
start = start.astimezone(pytz.timezone("Europe/Berlin"))
end = vevent.get("DTEND", None)
if end is not None:
end = end.dt
if not isinstance(end, datetime):
all_day_event = True
end = datetime.combine(end, datetime.max.time(), tzinfo=get_tzinfo())
end = end.astimezone(pytz.timezone("Europe/Berlin"))
def get_str(key: str):
value = vevent.get(key, None)
if value is not None:
return str(value)
return value
summary = get_str("SUMMARY")
description = get_str("DESCRIPTION")
location = get_str("LOCATION")
color = get_str("COLOR")
uid = get_str("UID")
event = CalendarEvent(start, end, all_day_event, summary, description, location, color, uid)
events.append(event)
events.sort(key=lambda e: e.start)
return events
async def get_future_events():
events = get_events_from_calendar(await get_data())
today = datetime.today().date()
future_events = []
for event in events:
if event.start.date() < today:
continue
future_events.append(event)
return future_events
def group_by_date(events: List[CalendarEvent]):
grouped_events: OrderedDict[date, List[CalendarEvent]] = OrderedDict()
for event in events:
start_date = event.start.date()
if start_date not in grouped_events:
grouped_events[start_date] = []
grouped_events[start_date].append(event)
return grouped_events

26
fabcal/legend.py Normal file
View File

@ -0,0 +1,26 @@
from typing import NamedTuple, List
import yaml
from asyncache import cached
from cachetools import FIFOCache
class LegendItem(NamedTuple):
color: str
description: str
link: str = None
# this will be cached permanently, i.e., the server process needs to be restarted to apply config changes
# note that caching doesn't work at all with iterators (for obvious reasons)
@cached(FIFOCache(1))
def read_legend_from_config_file() -> List[LegendItem]:
rv = []
with open("config.yml") as f:
data = yaml.safe_load(f)
for item in data.get("legend", []):
rv.append(LegendItem(**item))
return rv

14
fabcal/models.py Normal file
View File

@ -0,0 +1,14 @@
from datetime import datetime
from typing import NamedTuple
class CalendarEvent(NamedTuple):
start: datetime
end: datetime
# just a convenience thing, we want to keep start/date as datetime and save the client from guessing this themselves
all_day_event: bool
summary: str
description: str
location: str
color: str
uid: str

View File

@ -0,0 +1,35 @@
import re
from babel.dates import format_datetime
from jinja2 import pass_eval_context
from markupsafe import Markup, escape
from starlette.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
# custom filters
templates.env.filters["format_datetime"] = lambda value, format: format_datetime(value, format) #, locale="de_DE")
@pass_eval_context
def nl2br(eval_ctx, value):
br = "<br>\n"
if eval_ctx.autoescape:
value = escape(value)
br = Markup(br)
result = "\n\n".join(
# need to convert p to Markup again after applying re.split(...)
f"<p>{br.join(Markup(p).splitlines())}</p>"
for p in re.split(r"(?:\r\n|\r(?!\n)|\n){2,}", value)
)
if eval_ctx.autoescape:
result = Markup(result)
return result
templates.env.filters["nl2br"] = nl2br

17
fabcal/routers/api_v1.py Normal file
View File

@ -0,0 +1,17 @@
from fastapi import APIRouter
from starlette.responses import Response
from fabcal.calendar_client import get_data
router = APIRouter()
@router.get("/events.ics")
async def events_ics():
return Response(
(await get_data()).to_ical(True),
headers={
"content-type": "text/calendar",
},
)

View File

@ -0,0 +1,50 @@
from datetime import datetime
import babel.dates
from fastapi import APIRouter
from fastapi.requests import Request
from fastapi.responses import HTMLResponse
from fabcal.calendar_client import get_future_events, group_by_date
from fabcal.legend import read_legend_from_config_file
from fabcal.routers import templates
router = APIRouter()
async def generate_response(request: Request, template_name: str, **additional_context):
events = await get_future_events()
grouped_events = list(group_by_date(events).items())
context = {
"request": request,
"grouped_events": grouped_events,
"legend": read_legend_from_config_file(),
}
context.update(additional_context)
return templates.TemplateResponse(template_name, context=context)
@router.get("/sidebar/embed.html", response_class=HTMLResponse)
async def embed_sidebar(request: Request, max_days: int = None):
try:
max_days = int(max_days)
except TypeError:
pass
return await generate_response(request, "sidebar/embed.html", max_days=max_days)
@router.get("/sidebar/demo.html", response_class=HTMLResponse)
async def sidebar_demo(request: Request, max_days: int = None):
try:
max_days = int(max_days)
except TypeError:
pass
return await generate_response(request, "sidebar/demo.html", max_days=max_days)

225
main.py
View File

@ -1,225 +0,0 @@
import base64
import locale
import os
from collections import OrderedDict
from datetime import date, datetime, timedelta, timezone
from typing import List, NamedTuple
import aiohttp
import babel.dates
import recurring_ical_events
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.requests import Request
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi_cache import FastAPICache
from fastapi_cache.backends.inmemory import InMemoryBackend
from fastapi_cache.decorator import cache
from icalendar import Calendar, vText
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://fablab-altmuehlfranken.de",
"https://www.fablab-altmuehlfranken.de",
],
)
locale.setlocale(locale.LC_TIME, locale.getlocale())
def get_calendar_url():
url = os.environ["CALENDAR_URL"]
# convenience feature
url = url.replace("webcal://", "https://")
return url
def sanitize(data: str):
# sanitize input from random source
cal = Calendar.from_ical(data)
# name needs to be fixed
cal["X-WR-CALNAME"] = vText(b"FabLab-Termine")
return cal.to_ical()
# caching strings works better than caching calendar objects
@cache(expire=120)
async def get_data() -> str:
async with aiohttp.ClientSession() as session:
async with session.get(get_calendar_url()) as response:
response.raise_for_status()
assert response.content_type.lower() == "text/calendar"
return sanitize(await response.text())
class CalendarEvent(NamedTuple):
start: datetime
end: datetime
# just a convenience thing, we want to keep start/date as datetime and save the client from guessing this themselves
all_day_event: bool
summary: str
description: str
location: str
color: str
uid: str
def get_tzinfo():
return timezone(timedelta(hours=1))
def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]:
"""
Generate list of events from calendar vevents.
Expands recurring events for +- one year.
Note that there is no validation, e.g., checking for values required (by other code in this application).
:param cal: calendar to fetch events from
:return: events
"""
now = datetime.now()
td = timedelta(days=365)
past_year = now - td
next_year = now + td
events = []
for vevent in recurring_ical_events.of(cal).between(past_year, next_year):
# I'm pessimistic here. Prove me wrong!
all_day_event = False
start = vevent.get("DTSTART", None)
if start is not None:
start = start.dt
if not isinstance(start, datetime):
all_day_event = True
start = datetime.combine(start, datetime.min.time(), tzinfo=get_tzinfo())
end = vevent.get("DTEND", None)
if end is not None:
end = end.dt
if not isinstance(end, datetime):
all_day_event = True
end = datetime.combine(end, datetime.max.time(), tzinfo=get_tzinfo())
def get_str(key: str):
value = vevent.get(key, None)
if value is not None:
return str(value)
return value
summary = get_str("SUMMARY")
description = get_str("DESCRIPTION")
location = get_str("LOCATION")
color = get_str("COLOR")
uid = get_str("UID")
event = CalendarEvent(start, end, all_day_event, summary, description, location, color, uid)
events.append(event)
events.sort(key=lambda e: e.start)
return events
async def get_future_events():
cal = Calendar.from_ical(await get_data())
events = get_events_from_calendar_string(cal)
today = datetime.today().date()
future_events = []
for event in events:
if event.start.date() < today:
continue
future_events.append(event)
return future_events
@app.get("/events.ics")
async def ics():
return Response(
await get_data(),
headers={
"content-type": "text/calendar",
},
)
def group_by_date(events: List[CalendarEvent]):
grouped_events: OrderedDict[date, List[CalendarEvent]] = OrderedDict()
for event in events:
start_date = event.start.date()
if start_date not in grouped_events:
grouped_events[start_date] = []
grouped_events[start_date].append(event)
return grouped_events
@app.get("/embed-sidebar.html")
async def embed(request: Request, max_width: str = None):
# await asyncio.sleep(1)
events = await get_future_events()
grouped_events = list(group_by_date(events).items())
# couple of helpers
def localized_abbreviated_month(dt: datetime):
return babel.dates.format_datetime(dt, format="%b", locale="de_DE")
# couple of helpers
def localized_abbreviated_weekday(dt: datetime):
return babel.dates.format_datetime(dt, format="%b", locale="de_DE")
def base64_encode(s: str):
return base64.b64encode(s.encode()).decode()
return templates.TemplateResponse(
"embed-sidebar.html",
context={
"request": request,
"grouped_events": grouped_events,
"dir": dir,
"localized_abbreviated_month": localized_abbreviated_month,
"localized_abbreviated_weekday": localized_abbreviated_weekday,
"base64_encode": base64_encode,
"max_width": max_width,
},
)
@app.on_event("startup")
async def startup():
FastAPICache.init(InMemoryBackend())

10
package.json Normal file
View File

@ -0,0 +1,10 @@
{
"name": "fabcal",
"version": "0.0.1",
"author": "Fabian Müller <fabian@fablab-altmuehlfranken.de>",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.2.1",
"milligram": "^1.4.1"
}
}

2731
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,26 @@
[tool.poetry]
name = "fabcal"
version = "0.0.1"
description = ""
authors = ["Fabian Müller <fabian@fablab-altmuehlfranken.de>"]
include = [
{ path = "static", format = ["sdist", "wheel"] },
{ path = "templates", format = ["sdist", "wheel"] },
]
package-mode = false
[tool.poetry.dependencies]
python = "^3.8"
python = "^3.10"
icalendar = "^4.0.9"
fastapi = "^0.75.0"
fastapi = "^0.115.0"
uvicorn = {extras = ["standard"], version = "^0.17.6"}
Jinja2 = "^3.1.0"
Babel = "^2.9.1"
fastapi-cache2 = "^0.1.8"
aiohttp = {extras = ["speedups"], version = "^3.8.1"}
recurring-ical-events = "^1.0.1-beta.0"
pyyaml = "^6.0"
asyncache = "^0.3.1"
pytz = "^2025.1"
sentry-sdk = {extras = ["fastapi"], version = "^2.22.0"}
[tool.poetry.dev-dependencies]
black = "^22.1.0"
@ -31,4 +38,3 @@ profile = "black"
line_length = 120
lines_between_types = 1
lines_after_imports = 2
lines_between_sections = 2

View File

@ -13,7 +13,9 @@ body {
font-size: 9pt;
height: auto;
overflow: auto
overflow: auto;
/*background: repeating-linear-gradient(135deg, #eee, #eee 100px, #ccc 100px, #ccc 200px);*/
}
.calendar {
@ -26,7 +28,6 @@ body {
display: flex;
align-items: stretch;
flex-direction: row;
background-color: white;
}
.calendar-date-date {
@ -36,6 +37,7 @@ body {
margin-right: 8px;
flex: 35px 0 0;
height: 100%;
background-color: white;
}
.calendar-date-month {
background-color: var(--calendar-fablab-red);
@ -43,7 +45,7 @@ body {
padding: 3px 0;
}
.calendar-date:nth-of-type(2n) .calendar-date-month {
/*background-color: var(--calendar-fablab-blue);*/
background-color: var(--calendar-fablab-blue);
}
.calendar-date-day {
font-size: 13pt;
@ -96,10 +98,19 @@ body {
flex-direction: column;
justify-content: center;
}
.calendar-event-description p {
margin-bottom: 0.5em;
}
.calendar-event-description p:last-child {
margin-bottom: 0;
}
.calendar-event-type {
flex: 0 0 12px;
margin: -5px -5px -5px 0;
}
.calendar-subscription-buttons {
text-align: center;
}
@media only screen and (max-width: 960px) {
.calendar-event {
@ -134,6 +145,7 @@ body {
details.calendar-event-details > summary {
text-decoration: underline dotted black;
cursor: pointer;
}
.calendar-popover-content > * {
margin: 5px 0;
@ -155,3 +167,44 @@ details.calendar-event-details[open] summary ~ * {
0% {opacity: 0; transform: translateY(-10px)}
100% {opacity: 1; transform: translateY(0)}
}
.calendar-legend {
padding: 5px;
border: var(--calendar-border);
border-radius: 5px;
background-color: #eee;
margin: 12px 2px 2px 2px;
}
.calendar-legend-list {
margin: 0;
}
.calendar-legend-heading {
font-size: 18px;
margin-bottom: 6px;
text-decoration: underline black;
}
.calendar-legend-item {
margin-bottom: 4px;
list-style: none;
font-size: 13px;
display: flex;
flex-direction: row;
justify-content: left;
}
.calendar-legend-item-color {
display: inline-block;
width: 18px;
height: 18px;
margin-right: 4px;
}
.calendar-legend-item-description {
word-break: break-word;
word-wrap: break-word;
hyphens: auto;
display: flex;
flex-direction: column;
justify-content: center;
}
.calendar-legend-item-link {
display: flex;
}

View File

@ -1,68 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
{#- TODO: replace with locally served files #}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" type="text/css"/>
<link rel="stylesheet" href="{{ url_for('static', path='roboto.css') }}" type="text/css"/>
<title>Embeddable calendar</title>
</head>
<body{% if max_width %} style="max-width: {{ max_width }}"{% endif %}>
<div class="calendar">
{% for start_date, events in grouped_events[:3] %}
<div class="calendar-date">
<div class="calendar-date-date">
<div class="calendar-date-month">
{{ start_date.strftime("%b") }}
</div>
<div class="calendar-date-day">
{{ start_date.strftime("%d") }}
</div>
<div class="calendar-date-weekday">
{{ start_date.strftime("%a") }}
</div>
</div>
<div class="calendar-events">
{% for event in events %}
<div class="calendar-event" title="{{ event.summary }}{% if event.description %} &mdash; {{ event.description }}{% endif %}">
<div class="calendar-event-time">
<div class="calendar-event-starttime">{{ event.start.strftime("%H:%M") }}</div>
<div class="calendar-event-timesep"></div>
<div class="calendar-event-endtime">{{ event.end.strftime("%H:%M") }}</div>
</div>
<div class="calendar-event-description">
{% if event.description or event.location %}
<details class="calendar-event-details">
<summary>{{ event.summary }}</summary>
<div class="calendar-popover-content">
{% if event.description %}
<div class="calendar-popover-entry">
{#- description doesn't need an icon, just wastes space #}
<div class="calendar-popover-entry-text">{{ event.description }}</div>
</div>
{% endif %}
{% if event.location %}
<div class="calendar-popover-entry">
<div class="calendar-popover-entry-icon"><i class="fa-solid fa-location-dot"></i></div>
<div class="calendar-popover-entry-text">{{ event.location }}</div>
</div>
{% endif %}
</div>
</details>
{% else %}
<span>{{ event.summary }}</span>
{% endif %}
</div>
<div class="calendar-event-type" style="background-color: {{ event.color }};"></div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</body>
</html>

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
{% block header %}
<link rel="stylesheet" href="{{ url_for('normalize.css', path='normalize.css') }}">
<link rel="stylesheet" href="{{ url_for('milligram', path='dist/milligram.min.css') }}">
<link rel="stylesheet" href="{{ url_for('@fortawesome/fontawesome-free', path='css/all.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" type="text/css"/>
<link rel="stylesheet" href="{{ url_for('static', path='roboto.css') }}" type="text/css"/>
<title>Embeddable calendar</title>
{% endblock %}
</head>
<body>
{% block body %}
{% endblock %}
</body>
</html>

View File

@ -0,0 +1,64 @@
{% extends "sidebar/base.html" %}
{% block header %}
{{ super() }}
<style>
html {
display: flex;
}
body {
max-width: 1200px;
display: flex;
flex-direction: row;
margin: 0 auto;
}
.fake-content {
margin: 50px 30px 0 30px;
padding: 0;
background-color: white;
font-size: 18px;
}
.calendar {
min-width: 250px;
margin: 50px 30px 0 30px;
border: 1px solid red;
}
@media only screen and (max-width: 600px) {
body {
flex-wrap: wrap-reverse;
}
}
</style>
{% endblock %}
{% block body %}
<div class="fake-content">
<h2>Fake content</h2>
<p>
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et
dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet
clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet,
consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat,
sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea
takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed
diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et
accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum
dolor sit amet.
</p>
<p>
Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu
feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril
delenit augue duis dolore te feugait nulla facilisi.
</p>
</div>
<div class="calendar">
{% include "sidebar/includes/header.html" %}
{% include "sidebar/includes/events-list.html" %}
{% include "sidebar/includes/footer.html" %}
</div>
{% endblock %}

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
<link rel="stylesheet" href="{{ url_for('normalize.css', path='normalize.css') }}">
<link rel="stylesheet" href="{{ url_for('milligram', path='dist/milligram.min.css') }}">
<link rel="stylesheet" href="{{ url_for('@fortawesome/fontawesome-free', path='css/all.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" type="text/css"/>
<link rel="stylesheet" href="{{ url_for('static', path='roboto.css') }}" type="text/css"/>
<title>Embeddable calendar</title>
</head>
<body>
<div class="calendar">
{% include "sidebar/includes/header.html" %}
{% include "sidebar/includes/events-list.html" %}
{% include "sidebar/includes/footer.html" %}
</div>
</body>
</html>

View File

@ -0,0 +1,61 @@
{% if max_days %}
{% set grouped_events = grouped_events[:max_days] %}
{% endif %}
{% for start_date, events in grouped_events %}
<div class="calendar-date">
<div class="calendar-date-date">
<div class="calendar-date-month">
{{ start_date | format_datetime("MMM") }}
</div>
<div class="calendar-date-day">
{{ start_date | format_datetime("d") }}
</div>
<div class="calendar-date-weekday">
{{ start_date | format_datetime("EEE") }}
</div>
</div>
<div class="calendar-events">
{% for event in events %}
<div class="calendar-event" title="{{ event.summary }}{% if event.description %} &mdash; {{ event.description }}{% endif %}">
<div class="calendar-event-time">
<div class="calendar-event-starttime">{{ event.start | format_datetime("HH:mm") }}</div>
<div class="calendar-event-timesep"></div>
<div class="calendar-event-endtime">{{ event.end | format_datetime("HH:mm") }}</div>
</div>
<div class="calendar-event-description">
{% if event.description or event.location %}
<details class="calendar-event-details">
<summary>{{ event.summary | urlize }}</summary>
<div class="calendar-popover-content">
{% if event.description %}
<div class="calendar-popover-entry">
{#- description doesn't need an icon, just wastes space #}
<div class="calendar-popover-entry-text">{{ event.description | urlize | nl2br }}</div>
</div>
{% endif %}
{% if event.location %}
<div class="calendar-popover-entry">
<div class="calendar-popover-entry-icon"><i class="fa-solid fa-location-dot"></i></div>
<div class="calendar-popover-entry-text">{{ event.location | urlize | nl2br }}</div>
</div>
{% endif %}
</div>
</details>
{% else %}
<span>{{ event.summary }}</span>
{% endif %}
</div>
{%- if "AusbauLab" in event.categories %}
<div class="calendar-event-type calendar-event-type-ausbau"></div>
{%- elif event.color %}
<div class="calendar-event-type" style="background-color: {{ event.color }};"></div>
{%- else %}
<div class="calendar-event-type calendar-event-type-unknown"></div>
{%- endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}

View File

@ -0,0 +1,23 @@
<div class="calendar-subscription-buttons">
<a href="{{ url_for('events_ics') }}"><i class="fa-solid fa-calendar"></i> Kalender abonnieren</a>
</div>
{% if legend %}
<div class="calendar-legend">
<p class="calendar-legend-heading">Legende:</p>
<ul class="calendar-legend-list">
{% for item in legend %}
<li class="calendar-legend-item">
{% if item.link %}
<a class="calendar-legend-item-link" href="{{ item.link }}">
{% endif %}
<span class="calendar-legend-item-color" style="background-color: {{ item.color }};"></span>
<span class="calendar-legend-item-description">{{ item.description }}</span>
{% if item.link %}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>

View File

@ -0,0 +1 @@
<h3>Veranstaltungen</h3>

20
yarn.lock Normal file
View File

@ -0,0 +1,20 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@fortawesome/fontawesome-free@^6.2.1":
version "6.2.1"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.2.1.tgz#344baf6ff9eaad7a73cff067d8c56bfc11ae5304"
integrity sha512-viouXhegu/TjkvYQoiRZK3aax69dGXxgEjpvZW81wIJdxm5Fnvp3VVIP4VHKqX4SvFw6qpmkILkD4RJWAdrt7A==
milligram@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/milligram/-/milligram-1.4.1.tgz#6c8c781541b0d994ccca784c60f0aca1f7104b42"
integrity sha512-RCgh/boHhcXWOUfKJWm3RJRoUeaEguoipDg0mJ31G0tFfvcpWMUlO1Zlqqr12K4kAXfDlllaidu0x7PaL2PTFg==
dependencies:
normalize.css "~8.0.1"
normalize.css@~8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3"
integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==