Compare commits
21 Commits
9b8da8c2b6
...
main
Author | SHA1 | Date | |
---|---|---|---|
e82f7224c5 | |||
cdeca9498c | |||
2d90b57235 | |||
53cb75f1d5 | |||
54222eb8cc | |||
af33203932 | |||
93b0a314d4 | |||
d8fa4e1dcb | |||
e8b8150dd5 | |||
527d9642bc | |||
f2b25ce868 | |||
691f9181e1 | |||
b432503ac9 | |||
00b74e8782 | |||
70a069fe95 | |||
5d58ba89c4 | |||
eab91b7272 | |||
276110b2a2 | |||
bb2f840cec | |||
42153f2e27 | |||
3dc8a7f232 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -4,3 +4,6 @@ __pycache__/
|
||||
*.*swp*
|
||||
docker-compose.yml
|
||||
dist/
|
||||
node_modules/
|
||||
yarn*.log
|
||||
*.yml
|
||||
|
14
Dockerfile
14
Dockerfile
@ -1,18 +1,20 @@
|
||||
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 --only main
|
||||
RUN poetry install --only main && \
|
||||
yarn install
|
||||
|
||||
COPY fabcal /app/fabcal/
|
||||
COPY static/ /app/static/
|
||||
|
7
config.yml.example
Normal file
7
config.yml.example
Normal 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"
|
@ -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="*"
|
||||
|
||||
|
@ -1,18 +1,36 @@
|
||||
import locale
|
||||
import os
|
||||
|
||||
import sentry_sdk
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.backends.inmemory import InMemoryBackend
|
||||
|
||||
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="")
|
||||
|
||||
@ -28,6 +46,6 @@ app.add_middleware(
|
||||
locale.setlocale(locale.LC_TIME, locale.getlocale())
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
FastAPICache.init(InMemoryBackend())
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, debug=True)
|
||||
|
@ -1,53 +1,148 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import List
|
||||
from typing import Dict, List, NamedTuple
|
||||
|
||||
import aiohttp
|
||||
import pytz
|
||||
import recurring_ical_events
|
||||
import yaml
|
||||
|
||||
from fastapi_cache.decorator import cache
|
||||
from icalendar import Calendar, vText
|
||||
from asyncache import cached
|
||||
from cachetools import TTLCache, FIFOCache
|
||||
from icalendar import Calendar
|
||||
|
||||
from fabcal.models import CalendarEvent
|
||||
|
||||
|
||||
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()
|
||||
class ConfiguredCalendar(NamedTuple):
|
||||
name: str
|
||||
url: str
|
||||
default_color: str = None
|
||||
overwrite_summary: str = None
|
||||
overwrite_description: str = None
|
||||
overwrite_location: str = None
|
||||
|
||||
|
||||
def get_calendar_url():
|
||||
url = os.environ["CALENDAR_URL"]
|
||||
class CalendarClient:
|
||||
def __init__(self, url: str):
|
||||
self.url = url
|
||||
|
||||
# convenience feature
|
||||
url = url.replace("webcal://", "https://")
|
||||
|
||||
return url
|
||||
|
||||
|
||||
# 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:
|
||||
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 sanitize(await response.text())
|
||||
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_string(cal: Calendar) -> List[CalendarEvent]:
|
||||
def get_events_from_calendar(cal: Calendar) -> List[CalendarEvent]:
|
||||
"""
|
||||
Generate list of events from calendar vevents.
|
||||
Expands recurring events for +- one year.
|
||||
@ -75,6 +170,8 @@ def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]:
|
||||
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
|
||||
@ -83,6 +180,8 @@ def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]:
|
||||
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)
|
||||
|
||||
@ -106,9 +205,7 @@ def get_events_from_calendar_string(cal: Calendar) -> List[CalendarEvent]:
|
||||
|
||||
|
||||
async def get_future_events():
|
||||
cal = Calendar.from_ical(await get_data())
|
||||
|
||||
events = get_events_from_calendar_string(cal)
|
||||
events = get_events_from_calendar(await get_data())
|
||||
|
||||
today = datetime.today().date()
|
||||
|
||||
|
26
fabcal/legend.py
Normal file
26
fabcal/legend.py
Normal 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
|
@ -1,5 +1,6 @@
|
||||
import re
|
||||
|
||||
from babel.dates import format_datetime
|
||||
from jinja2 import pass_eval_context
|
||||
from markupsafe import Markup, escape
|
||||
from starlette.templating import Jinja2Templates
|
||||
@ -7,6 +8,9 @@ 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):
|
||||
|
@ -10,7 +10,7 @@ router = APIRouter()
|
||||
@router.get("/events.ics")
|
||||
async def events_ics():
|
||||
return Response(
|
||||
await get_data(),
|
||||
(await get_data()).to_ical(True),
|
||||
headers={
|
||||
"content-type": "text/calendar",
|
||||
},
|
||||
|
@ -1,5 +1,3 @@
|
||||
import base64
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import babel.dates
|
||||
@ -9,6 +7,7 @@ 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
|
||||
|
||||
|
||||
@ -20,41 +19,32 @@ async def generate_response(request: Request, template_name: str, **additional_c
|
||||
|
||||
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()
|
||||
|
||||
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,
|
||||
"legend": read_legend_from_config_file(),
|
||||
}
|
||||
|
||||
context.update(additional_context)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
template_name,
|
||||
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,
|
||||
},
|
||||
)
|
||||
return templates.TemplateResponse(template_name, context=context)
|
||||
|
||||
|
||||
@router.get("/sidebar/embed.html", response_class=HTMLResponse)
|
||||
async def embed_sidebar(request: Request):
|
||||
return await generate_response(request, "sidebar/embed.html")
|
||||
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)
|
||||
|
10
package.json
Normal file
10
package.json
Normal 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"
|
||||
}
|
||||
}
|
2830
poetry.lock
generated
2830
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,23 +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"
|
||||
|
@ -45,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;
|
||||
@ -145,6 +145,7 @@ body {
|
||||
|
||||
details.calendar-event-details > summary {
|
||||
text-decoration: underline dotted black;
|
||||
cursor: pointer;
|
||||
}
|
||||
.calendar-popover-content > * {
|
||||
margin: 5px 0;
|
||||
@ -166,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;
|
||||
}
|
||||
|
@ -2,10 +2,9 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
{% block header %}
|
||||
{#- 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('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"/>
|
||||
|
64
templates/sidebar/demo.html
Normal file
64
templates/sidebar/demo.html
Normal 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 %}
|
@ -1,10 +1,9 @@
|
||||
<!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('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"/>
|
||||
|
@ -1,23 +1,27 @@
|
||||
{% 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.strftime("%b") }}
|
||||
{{ start_date | format_datetime("MMM") }}
|
||||
</div>
|
||||
<div class="calendar-date-day">
|
||||
{{ start_date.strftime("%d") }}
|
||||
{{ start_date | format_datetime("d") }}
|
||||
</div>
|
||||
<div class="calendar-date-weekday">
|
||||
{{ start_date.strftime("%a") }}
|
||||
{{ 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 %} — {{ event.description }}{% endif %}">
|
||||
<div class="calendar-event-time">
|
||||
<div class="calendar-event-starttime">{{ event.start.strftime("%H:%M") }}</div>
|
||||
<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.strftime("%H:%M") }}</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 %}
|
||||
|
@ -1,3 +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>
|
||||
|
20
yarn.lock
Normal file
20
yarn.lock
Normal 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==
|
Reference in New Issue
Block a user