Merge branch 'release/0.1.0'

This commit is contained in:
Jörn-Michael Miehe 2026-02-16 01:10:56 +00:00
commit 79bf655cbb
92 changed files with 19143 additions and 0 deletions

27
.dockerignore Normal file
View file

@ -0,0 +1,27 @@
# commonly found
**/.git
**/.idea
**/.DS_Store
**/.vscode
**/.devcontainer
**/dist
**/.gitignore
**/Dockerfile
**/.dockerignore
# found in python and JS dirs
**/__pycache__
**/node_modules
**/.pytest_cache
# env files
**/.env
**/.env.local
**/.env.*.local
# log files
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
**/pnpm-debug.log*

129
Dockerfile Normal file
View file

@ -0,0 +1,129 @@
ARG NODE_VERSION=24
ARG PYTHON_VERSION=3.14-slim
#############
# build api #
#############
ARG PYTHON_VERSION
FROM python:${PYTHON_VERSION} AS build-api
# env setup
WORKDIR /usr/local/src/advent22_api
ENV \
PATH="/root/.local/bin:${PATH}"
# install poetry with export plugin
RUN set -ex; \
\
python -m pip --no-cache-dir install --upgrade pip wheel; \
\
apt-get update; apt-get install --yes --no-install-recommends \
curl \
; rm -rf /var/lib/apt/lists/*; \
\
curl -sSL https://install.python-poetry.org | python3 -; \
poetry self add poetry-plugin-export;
# build dependency wheels
COPY api/pyproject.toml api/poetry.lock ./
RUN set -ex; \
\
# # buildtime dependencies
# apt-get update; apt-get install --yes --no-install-recommends \
# build-essential \
# ; rm -rf /var/lib/apt/lists/*; \
\
# generate requirements.txt
poetry export \
--format requirements.txt \
--output requirements.txt; \
\
python3 -m pip --no-cache-dir wheel \
--wheel-dir ./dist \
--requirement requirements.txt;
# build advent22_api wheel
COPY api ./
RUN poetry build --format wheel --output ./dist
############
# build ui #
############
ARG NODE_VERSION
FROM node:${NODE_VERSION} AS build-ui
# env setup
WORKDIR /usr/local/src/advent22_ui
# install advent22_ui dependencies
COPY ui/package*.json ui/yarn*.lock ./
RUN set -ex; \
corepack enable; \
yarn install;
# copy and build advent22_ui
COPY ui ./
RUN set -ex; \
yarn dlx update-browserslist-db@latest; \
yarn build --dest /tmp/advent22_ui/html; \
# exclude webpack-bundle-analyzer output
rm -f /tmp/advent22_ui/html/report.html;
######################
# python preparation #
######################
ARG PYTHON_VERSION
FROM python:${PYTHON_VERSION} AS uvicorn-gunicorn
# where credit is due ...
LABEL maintainer="Sebastián Ramirez <tiangolo@gmail.com>"
WORKDIR /usr/local/share/uvicorn-gunicorn
# install uvicorn-gunicorn
COPY ./scripts/mini-tiangolo ./
RUN set -ex; \
chmod +x start.sh; \
python3 -m pip --no-cache-dir install gunicorn;
CMD ["/usr/local/share/uvicorn-gunicorn/start.sh"]
###########
# web app #
###########
FROM uvicorn-gunicorn AS production
# env setup
ENV \
PRODUCTION_MODE="true" \
PORT="8000" \
MODULE_NAME="advent22_api.app"
EXPOSE 8000
WORKDIR /opt/advent22
VOLUME [ "/opt/advent22" ]
COPY --from=build-api /usr/local/src/advent22_api/dist /usr/local/share/advent22_api.dist
RUN set -ex; \
# remove example app
rm -rf /app; \
\
# # runtime dependencies
# apt-get update; apt-get install --yes --no-install-recommends \
# ; rm -rf /var/lib/apt/lists/*; \
\
# install advent22_api wheels
python3 -m pip --no-cache-dir install --no-deps /usr/local/share/advent22_api.dist/*.whl; \
\
# prepare data directory
chown nobody:nogroup ./
# add prepared advent22_ui
COPY --from=build-ui /tmp/advent22_ui /usr/local/share/advent22_ui
# run as unprivileged user
USER nobody

23
Ideen.md Normal file
View file

@ -0,0 +1,23 @@
# MUSS
# KANN
- api/ui: Türchen mit Tag "0" einem zufälligen Tag zuweisen
- api/?: Option "custom Zuordnung Buchstaben" (standard leer)
- ui: `confirm` durch bulma Komponente(n) ersetzen
- halbautomatischer Modus: Finde Bilder wie "a.jpg" und "Z.png" und weise diese den passenden Tagen zu
# Erledigt
- Türchen anzeigen im DoorMapEditor
- Lösungsbuchstaben weniger als türchen erzeugt bug
- Türchen sichtbar machen (besser für touch, standard nein)
- Option "Nur Groß-/Kleinbuchstaben" (standard nur groß)
- Option "Leerzeichen ignorieren" (standard ja)
- Nach einigen Sekunden: Meldung "Türchen anzeigen?"
- `alert` durch bulma Komponente(n) ersetzen
- api: admin Login case sensitivity (username "admin" == "AdMiN")
- api: `config.solution` - whitespace="IGNORE"->"REMOVE" umbenennen, +Sonderzeichen
- api: Config-Option "Überspringe leere Türchen" (standard ja)
- api: Config-Liste von Extra-Türchen (kein Buchstabe, nur manuelles Bild)

View file

@ -0,0 +1,56 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/python
{
"name": "Advent22 API",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/python:3-3.14-trixie",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/git-lfs:1": {},
"ghcr.io/devcontainers-extra/features/poetry:2": {},
"ghcr.io/devcontainers-extra/features/apt-get-packages:1": {
"packages": "git-flow"
},
"ghcr.io/itsmechlark/features/redis-server:1": {}
},
"containerEnv": {
"TZ": "Europe/Berlin"
},
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Set *default* container specific settings.json values on container create.
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python",
"terminal.integrated.defaultProfile.linux": "zsh"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"be5invis.toml",
"mhutchie.git-graph",
"ms-python.python",
"ms-python.black-formatter",
"ms-python.flake8",
"ms-python.isort",
"ms-python.vscode-pylance"
]
}
},
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "sudo /usr/local/py-utils/bin/poetry self add poetry-plugin-up",
// Use 'postStartCommand' to run commands after the container is started.
"postStartCommand": "poetry install"
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

4
api/.flake8 Normal file
View file

@ -0,0 +1,4 @@
[flake8]
max-line-length = 80
extend-select = B950
extend-ignore = E203,E501

View file

@ -152,3 +152,4 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
api.conf

3
api/.isort.cfg Normal file
View file

@ -0,0 +1,3 @@
[settings]
profile = black
line_length = 80

22
api/.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,22 @@
{
// Verwendet IntelliSense zum Ermitteln möglicher Attribute.
// Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen.
// Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Main Module",
"type": "python",
"request": "launch",
"module": "advent22_api.main",
"pythonArgs": [
"-Xfrozen_modules=off",
],
"env": {
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
"WEBDAV__CACHE_TTL": "30",
},
"justMyCode": true,
}
]
}

33
api/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,33 @@
{
"git.closeDiffOnOperation": true,
"[python]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.fixAll": "explicit",
},
},
"python.languageServer": "Pylance",
"python.analysis.autoImportCompletions": true,
"python.analysis.importFormat": "relative",
"python.analysis.fixAll": [
"source.convertImportFormat",
"source.unusedImports",
],
"python.analysis.typeCheckingMode": "basic",
"python.analysis.diagnosticMode": "workspace",
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": [
"--import-mode=importlib",
"test",
],
"black-formatter.importStrategy": "fromEnvironment",
"flake8.importStrategy": "fromEnvironment",
"isort.importStrategy": "fromEnvironment",
}

46
api/advent22_api/app.py Normal file
View file

@ -0,0 +1,46 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from .core.settings import SETTINGS
from .routers import router
app = FastAPI(
title="Advent22 API",
description="This API enables the `Advent22` service.",
contact={
"name": "Jörn-Michael Miehe",
"email": "jmm@yavook.de",
},
license_info={
"name": "MIT License",
"url": "https://opensource.org/licenses/mit-license.php",
},
openapi_url=SETTINGS.openapi_url,
docs_url=SETTINGS.docs_url,
redoc_url=SETTINGS.redoc_url,
)
app.include_router(router)
if SETTINGS.production_mode:
# Mount frontend in production mode
app.mount(
path="/",
app=StaticFiles(
directory=SETTINGS.ui_directory,
html=True,
),
name="frontend",
)
else:
# Allow CORS in debug mode
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)

View file

View file

@ -0,0 +1,148 @@
import colorsys
import logging
from dataclasses import dataclass
from typing import AnyStr, Self, TypeAlias
import numpy as np
from PIL import Image as PILImage
from PIL import ImageDraw
from PIL.Image import Image, Resampling
from PIL.ImageFont import FreeTypeFont
from .config import Config
_RGB: TypeAlias = tuple[int, int, int]
_XY: TypeAlias = tuple[float, float]
_Box: TypeAlias = tuple[int, int, int, int]
_logger = logging.getLogger(__name__)
@dataclass(slots=True, frozen=True)
class AdventImage:
img: Image
@classmethod
async def from_img(cls, img: Image, cfg: Config) -> Self:
"""
Einen quadratischen Ausschnitt aus der Mitte des Bilds nehmen
"""
# Farbmodell festlegen
img = img.convert(mode="RGB")
# Größen bestimmen
width, height = img.size
square = min(width, height)
# zuschneiden
img = img.crop(
box=(
int((width - square) / 2),
int((height - square) / 2),
int((width + square) / 2),
int((height + square) / 2),
)
)
# skalieren
return cls(
img.resize(
size=(cfg.image.size, cfg.image.size),
resample=Resampling.LANCZOS,
)
)
async def get_text_box(
self,
xy: _XY,
text: AnyStr,
font: FreeTypeFont,
anchor: str | None = "mm",
**text_kwargs,
) -> _Box | None:
"""
Koordinaten (links, oben, rechts, unten) des betroffenen
Rechtecks bestimmen, wenn das Bild mit einem Text
versehen wird
"""
# Neues 1-Bit Bild, gleiche Größe
mask = PILImage.new(mode="1", size=self.img.size)
# Text auf Maske auftragen
ImageDraw.Draw(mask).text(
xy=xy,
text=text,
font=font,
anchor=anchor,
fill=1,
**text_kwargs,
)
# betroffenen Pixelbereich bestimmen
return mask.getbbox()
async def get_average_color(
self,
box: _Box,
) -> _RGB:
"""
Durchschnittsfarbe eines rechteckigen Ausschnitts in
einem Bild berechnen
"""
pixel_data = np.asarray(self.img.crop(box))
mean_color: np.ndarray = np.mean(pixel_data, axis=(0, 1))
return _RGB(mean_color.astype(int))
async def hide_text(
self,
xy: _XY,
text: AnyStr,
font: FreeTypeFont,
anchor: str | None = "mm",
**text_kwargs,
) -> None:
"""
Text `text` in Bild an Position `xy` verstecken.
Weitere Parameter wie bei `ImageDraw.text()`.
"""
# betroffenen Bildbereich bestimmen
text_box = await self.get_text_box(
xy=xy, text=text, font=font, anchor=anchor, **text_kwargs
)
if text_box is None:
_logger.warning("Konnte Bildbereich nicht finden!")
return
# Durchschnittsfarbe bestimmen
text_color = await self.get_average_color(
box=text_box,
)
# etwas heller/dunkler machen
tc_h, tc_s, tc_v = colorsys.rgb_to_hsv(*text_color)
tc_v = int((tc_v - 127) * 0.97) + 127
if tc_v < 127:
tc_v += 3
else:
tc_v -= 3
text_color = colorsys.hsv_to_rgb(tc_h, tc_s, tc_v)
text_color = _RGB(int(val) for val in text_color)
# Buchstaben verstecken
ImageDraw.Draw(self.img).text(
xy=xy,
text=text,
font=font,
fill=text_color,
anchor=anchor,
**text_kwargs,
)

View file

@ -0,0 +1,55 @@
import tomllib
from typing import TypeAlias
import tomli_w
from fastapi import Depends
from pydantic import BaseModel
from .config import Config, get_config
from .dav.webdav import WebDAV
class DoorSaved(BaseModel):
# Tag, an dem die Tür aufgeht
day: int
# Koordinaten für zwei Eckpunkte
x1: int
y1: int
x2: int
y2: int
DoorsSaved: TypeAlias = list[DoorSaved]
class CalendarConfig(BaseModel):
# Dateiname Hintergrundbild
background: str = "adventskalender.jpg"
# Dateiname Favicon
favicon: str = "favicon.png"
# Türen für die UI
doors: DoorsSaved = []
async def change(self, cfg: Config) -> None:
"""
Kalender Konfiguration ändern
"""
await WebDAV.write_str(
path=f"files/{cfg.calendar}",
content=tomli_w.dumps(self.model_dump()),
)
async def get_calendar_config(
cfg: Config = Depends(get_config),
) -> CalendarConfig:
"""
Kalender Konfiguration lesen
"""
txt = await WebDAV.read_str(path=f"files/{cfg.calendar}")
return CalendarConfig.model_validate(tomllib.loads(txt))

View file

@ -0,0 +1,81 @@
import tomllib
from markdown import markdown
from pydantic import BaseModel, ConfigDict, field_validator
from .dav.webdav import WebDAV
from .settings import SETTINGS, Credentials
from .transformed_string import TransformedString
class Site(BaseModel):
model_config = ConfigDict(validate_default=True)
# Titel
title: str
# Untertitel
subtitle: str
# Inhalt der Seite
content: str
# Fußzeile der Seite
footer: str = "**Advent22** by [Lenaisten e.V.](//www.lenaisten.de)"
@field_validator("content", "footer", mode="after")
def parse_md(cls, v) -> str:
return markdown(v)
class Puzzle(BaseModel):
# Tag, an dem der Kalender startet
begin_day: int = 1
# Monat, in dem der Kalender startet
begin_month: int = 12
# Kalender so viele Tage nach der letzten Türöffnung schließen
close_after: int = 90
# Tage, für die kein Buchstabe vorgesehen wird
extra_days: set[int] = set()
# Türchen ohne Buchstabe überspringen
skip_empty: bool = True
class Image(BaseModel):
# Quadrat, Seitenlänge in px
size: int = 1000
# Rand in px, wo keine Buchstaben untergebracht werden
border: int = 60
class Config(BaseModel):
# Login-Daten für Admin-Modus
admin: Credentials
# Lösungswort
solution: TransformedString
# Weitere Einstellungen
site: Site
puzzle: Puzzle
image: Image
# Kalenderdefinition
calendar: str = "default.toml"
# Serverseitiger zusätzlicher "random" seed
random_seed: str = ""
async def get_config() -> Config:
"""
Globale Konfiguration lesen
"""
txt = await WebDAV.read_str(path=SETTINGS.webdav.config_filename)
return Config.model_validate(tomllib.loads(txt))

View file

View file

@ -0,0 +1,60 @@
from json import JSONDecodeError
from typing import Callable, Hashable
import requests
from cachetools.keys import hashkey
from CacheToolsUtils import RedisCache as __RedisCache
from redis.typing import EncodableT, ResponseT
from webdav3.client import Client as __WebDAVclient
def davkey(
name: str,
slice: slice = slice(1, None),
) -> Callable[..., tuple[Hashable, ...]]:
def func(*args, **kwargs) -> tuple[Hashable, ...]:
"""Return a cache key for use with cached methods."""
key = hashkey(name, *args[slice], **kwargs)
return hashkey(*(str(key_item) for key_item in key))
return func
class WebDAVclient(__WebDAVclient):
def execute_request(
self,
action,
path,
data=None,
headers_ext=None,
) -> requests.Response:
res = super().execute_request(action, path, data, headers_ext)
# the "Content-Length" header can randomly be missing on txt files,
# this should fix that (probably serverside bug)
if action == "download" and "Content-Length" not in res.headers:
res.headers["Content-Length"] = str(len(res.text))
return res
class RedisCache(__RedisCache):
"""
Redis handles <bytes>, so ...
"""
def _serialize(self, s) -> EncodableT:
if isinstance(s, bytes):
return s
else:
return super()._serialize(s)
def _deserialize(self, s: ResponseT):
try:
return super()._deserialize(s)
except (UnicodeDecodeError, JSONDecodeError):
assert isinstance(s, bytes)
return s

View file

@ -0,0 +1,108 @@
import logging
import re
from io import BytesIO
from asyncify import asyncify
from cachetools import cachedmethod
from redis import Redis
from ..settings import SETTINGS
from .helpers import RedisCache, WebDAVclient, davkey
_logger = logging.getLogger(__name__)
class WebDAV:
_webdav_client = WebDAVclient(
{
"webdav_hostname": SETTINGS.webdav.url,
"webdav_login": SETTINGS.webdav.auth.username,
"webdav_password": SETTINGS.webdav.auth.password,
}
)
_cache = RedisCache(
cache=Redis(
host=SETTINGS.redis.host,
port=SETTINGS.redis.port,
db=SETTINGS.redis.db,
protocol=SETTINGS.redis.protocol,
),
ttl=SETTINGS.webdav.cache_ttl,
)
@classmethod
@asyncify
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("list_files"))
def list_files(
cls,
directory: str = "",
*,
regex: re.Pattern[str] = re.compile(""),
) -> list[str]:
"""
List files in directory `directory` matching RegEx `regex`
"""
_logger.debug(f"list_files {directory!r}")
ls = cls._webdav_client.list(directory)
return [path for path in ls if regex.search(path)]
@classmethod
@asyncify
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("exists"))
def exists(cls, path: str) -> bool:
"""
`True` iff there is a WebDAV resource at `path`
"""
_logger.debug(f"file_exists {path!r}")
return cls._webdav_client.check(path)
@classmethod
@asyncify
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("read_bytes"))
def read_bytes(cls, path: str) -> bytes:
"""
Load WebDAV file from `path` as bytes
"""
_logger.debug(f"read_bytes {path!r}")
buffer = BytesIO()
cls._webdav_client.download_from(buffer, path)
buffer.seek(0)
return buffer.read()
@classmethod
async def read_str(cls, path: str, encoding="utf-8") -> str:
"""
Load WebDAV file from `path` as string
"""
_logger.debug(f"read_str {path!r}")
return (await cls.read_bytes(path)).decode(encoding=encoding).strip()
@classmethod
@asyncify
def write_bytes(cls, path: str, buffer: bytes) -> None:
"""
Write bytes from `buffer` into WebDAV file at `path`
"""
_logger.debug(f"write_bytes {path!r}")
cls._webdav_client.upload_to(buffer, path)
# invalidate cache entry
# explicit slice as there is no "cls" argument
del cls._cache[davkey("read_bytes", slice(0, None))(path)]
@classmethod
async def write_str(cls, path: str, content: str, encoding="utf-8") -> None:
"""
Write string from `content` into WebDAV file at `path`
"""
_logger.debug(f"write_str {path!r}")
await cls.write_bytes(path, content.encode(encoding=encoding))

View file

@ -0,0 +1,230 @@
import re
from dataclasses import dataclass
from datetime import date
from io import BytesIO
from typing import cast
from fastapi import Depends
from PIL import ImageFont
from PIL.Image import Image
from PIL.ImageFont import FreeTypeFont
from .advent_image import _XY, AdventImage
from .calendar_config import CalendarConfig, get_calendar_config
from .config import Config, get_config
from .dav.webdav import WebDAV
from .helpers import (
RE_TTF,
EventDates,
Random,
list_fonts,
list_images_auto,
list_images_manual,
load_image,
set_len,
)
RE_NUM = re.compile(r"/(\d+)\.", flags=re.IGNORECASE)
async def get_all_sorted_days(
cal_cfg: CalendarConfig = Depends(get_calendar_config),
) -> list[int]:
"""
Alle Tage, für die es ein Türchen gibt
"""
return sorted(set(door.day for door in cal_cfg.doors))
async def get_all_parts(
cfg: Config = Depends(get_config),
days: list[int] = Depends(get_all_sorted_days),
) -> dict[int, str]:
"""
Lösung auf vorhandene Tage aufteilen
"""
# noch keine Buchstaben verteilt
result = {day: "" for day in days}
# extra-Tage ausfiltern
days = [day for day in days if day not in cfg.puzzle.extra_days]
solution_length = len(cfg.solution.clean)
num_days = len(days)
rnd = await Random.get()
solution_days = [
# wie oft passen die Tage "ganz" in die Länge der Lösung?
# zB 26 Buchstaben // 10 Tage == 2 mal => 2 Zeichen pro Tag
*rnd.shuffled(days * (solution_length // num_days)),
# wie viele Buchstaben bleiben übrig?
# zB 26 % 10 == 6 Buchstaben => an 6 Tagen ein Zeichen mehr
*rnd.sample(days, solution_length % num_days),
]
for day, letter in zip(solution_days, cfg.solution.clean):
result[day] += letter
return result
async def get_all_event_dates(
cfg: Config = Depends(get_config),
days: list[int] = Depends(get_all_sorted_days),
parts: dict[int, str] = Depends(get_all_parts),
) -> EventDates:
"""
Aktueller Kalender-Zeitraum
"""
if cfg.puzzle.skip_empty:
days = [day for day in days if parts[day] != "" or day in cfg.puzzle.extra_days]
return EventDates(
today=date.today(),
begin_month=cfg.puzzle.begin_month,
begin_day=cfg.puzzle.begin_day,
events=days,
close_after=cfg.puzzle.close_after,
)
async def get_all_auto_image_names(
days: list[int] = Depends(get_all_sorted_days),
images: list[str] = Depends(list_images_auto),
) -> dict[int, str]:
"""
Bilder: Reihenfolge zufällig bestimmen
"""
rnd = await Random.get()
ls = set_len(images, len(days))
return dict(zip(days, rnd.shuffled(ls)))
async def get_all_manual_image_names(
manual_image_names: list[str] = Depends(list_images_manual),
) -> dict[int, str]:
"""
Bilder: "manual" zuordnen
"""
return {
int(num_match.group(1)): name
for name in manual_image_names
if (num_match := RE_NUM.search(name)) is not None
}
async def get_all_image_names(
auto_image_names: dict[int, str] = Depends(get_all_auto_image_names),
manual_image_names: dict[int, str] = Depends(get_all_manual_image_names),
) -> dict[int, str]:
"""
Bilder "auto" und "manual" zu Tagen zuordnen
"""
result = auto_image_names.copy()
result.update(manual_image_names)
return result
@dataclass(slots=True, frozen=True)
class TTFont:
# Dateiname
file_name: str
# Schriftgröße für den Font
size: int = 50
@property
async def font(self) -> FreeTypeFont:
return ImageFont.truetype(
font=BytesIO(await WebDAV.read_bytes(self.file_name)),
size=100,
)
async def get_all_ttfonts(
font_names: list[str] = Depends(list_fonts),
) -> list[TTFont]:
result = []
for name in font_names:
assert (size_match := RE_TTF.search(name)) is not None
result.append(
TTFont(
file_name=name,
size=int(size_match.group(1)),
)
)
return result
async def gen_day_auto_image(
day: int,
cfg: Config,
auto_image_names: dict[int, str],
day_parts: dict[int, str],
ttfonts: list[TTFont],
) -> Image:
"""
Automatisch generiertes Bild erstellen
"""
# Datei existiert garantiert!
img = await load_image(auto_image_names[day])
image = await AdventImage.from_img(img, cfg)
rnd = await Random.get(day)
xy_range = range(cfg.image.border, (cfg.image.size - cfg.image.border))
# Buchstaben verstecken
for letter in day_parts[day]:
await image.hide_text(
xy=cast(_XY, tuple(rnd.choices(xy_range, k=2))),
text=letter,
font=await rnd.choice(ttfonts).font,
)
return image.img
async def get_day_image(
day: int,
days: list[int] = Depends(get_all_sorted_days),
cfg: Config = Depends(get_config),
manual_image_names: dict[int, str] = Depends(get_all_manual_image_names),
auto_image_names: dict[int, str] = Depends(get_all_auto_image_names),
day_parts: dict[int, str] = Depends(get_all_parts),
ttfonts: list[TTFont] = Depends(get_all_ttfonts),
) -> Image | None:
"""
Bild für einen Tag abrufen
"""
if day not in days:
return None
try:
# Versuche "manual"-Bild zu laden
img = await load_image(manual_image_names[day])
# Als AdventImage verarbeiten
image = await AdventImage.from_img(img, cfg)
return image.img
except (KeyError, RuntimeError):
# Erstelle automatisch generiertes Bild
return await gen_day_auto_image(
day=day,
cfg=cfg,
auto_image_names=auto_image_names,
day_parts=day_parts,
ttfonts=ttfonts,
)

View file

@ -0,0 +1,245 @@
import base64
import itertools
import random
import re
from datetime import date, datetime, timedelta
from io import BytesIO
from typing import Any, Awaitable, Callable, Iterable, Self, Sequence, TypeVar
from PIL import Image as PILImage
from PIL.Image import Image, Resampling
from pydantic import BaseModel
from .config import get_config
from .dav.webdav import WebDAV
T = TypeVar("T")
RE_IMG = re.compile(r"\.(gif|jpe?g|tiff?|png|bmp)$", flags=re.IGNORECASE)
RE_TTF = re.compile(r"_(\d+)\.ttf$", flags=re.IGNORECASE)
class Random(random.Random):
@classmethod
async def get(cls, bonus_salt: Any = "") -> Self:
cfg = await get_config()
return cls(f"{cfg.solution.clean}{cfg.random_seed}{bonus_salt}")
def shuffled(self, population: Sequence[T]) -> Sequence[T]:
return self.sample(population, k=len(population))
def set_len(seq: Sequence[T], len: int) -> Sequence[T]:
# `seq` unendlich wiederholen
infinite = itertools.cycle(seq)
# Die ersten `length` einträge nehmen
return list(itertools.islice(infinite, len))
def spread(
given: Iterable[int],
n: int,
rnd: Random | None = None,
) -> list[int]:
"""
Zu `given` ganzen Zahlen `n` zusätzliche Zahlen hinzunehmen.
- Die neuen Werte sind im selben Zahlenbereich wie `given`
- Zuerst werden alle Werte "zwischen" den `given` Werten genommen
"""
if n == 0:
return []
if len(set(given)) > 1:
range_given = range(min(given), max(given) + 1)
first_round = set(range_given) - set(given)
elif len(set(given)) == 1:
if (a := next(iter(given))) > 0:
range_given = range(1, a + 1)
else:
range_given = range(1, n + 1)
first_round = set(range_given) - set(given)
else:
range_given = range(1, n + 1)
first_round = range_given
result = sorted(first_round)[: min(n, len(first_round))]
full_rounds = (n - len(result)) // len(range_given)
result += list(range_given) * full_rounds
remain = n - len(result)
if rnd is None:
result += list(range_given)[:remain]
else:
result += rnd.sample(range_given, remain)
rnd.shuffle(result)
return result
def list_helper(
directory: str,
regex: re.Pattern[str],
) -> Callable[[], Awaitable[list[str]]]:
"""
Finde alle Dateien im Verzeichnis `dir`, passend zu `re`
"""
async def _list_helper() -> list[str]:
return [
f"{directory}/{file}"
for file in await WebDAV.list_files(directory=directory, regex=regex)
]
return _list_helper
list_images_auto = list_helper("/images_auto", RE_IMG)
list_images_manual = list_helper("/images_manual", RE_IMG)
list_fonts = list_helper("/files", RE_TTF)
async def load_image(file_name: str) -> Image:
"""
Versuche, Bild aus Datei zu laden
"""
if not await WebDAV.exists(file_name):
raise RuntimeError(f"DAV-File {file_name} does not exist!")
return PILImage.open(BytesIO(await WebDAV.read_bytes(file_name)))
class ImageData(BaseModel):
width: int
height: int
aspect_ratio: float
data_url: str
@classmethod
def create(
cls,
*,
media_type: str,
content: BytesIO,
width: int,
height: int,
) -> Self:
img_data = base64.b64encode(content.getvalue()).decode("utf-8")
return cls(
width=width,
height=height,
aspect_ratio=width / height,
data_url=f"data:{media_type};base64,{img_data}",
)
async def api_return_ico(img: Image) -> ImageData:
"""
ICO-Bild mit API zurückgeben
"""
# ICO-Daten in Puffer speichern (256px)
img_buffer = BytesIO()
img.resize(size=(256, 256), resample=Resampling.LANCZOS)
img.save(img_buffer, format="ICO")
# zurückgeben
return ImageData.create(
media_type="image/x-icon",
content=img_buffer,
width=img.width,
height=img.height,
)
async def api_return_jpeg(img: Image) -> ImageData:
"""
JPEG-Bild mit API zurückgeben
"""
# JPEG-Daten in Puffer speichern
img_buffer = BytesIO()
img.save(img_buffer, format="JPEG", quality=85)
# zurückgeben
return ImageData.create(
media_type="image/jpeg",
content=img_buffer,
width=img.width,
height=img.height,
)
class EventDates:
"""
Events in einem Ereigniszeitraum
"""
__overall_duration: timedelta
dates: dict[int, date]
@property
def first(self) -> date:
"""Datum des ersten Ereignisses"""
return self.dates[min(self.dates.keys())]
def get_next(self, *, today: date) -> date | None:
"""Datum des nächsten Ereignisses"""
return next(
(event for event in sorted(self.dates.values()) if event > today), None
)
@property
def next(self) -> date | None:
"""Datum des nächsten Ereignisses"""
return self.get_next(today=date.today())
@property
def last(self) -> date:
"""Datum des letzten Ereignisses"""
return self.dates[max(self.dates.keys())]
@property
def end(self) -> date:
"""Letztes Datum des Ereigniszeitraums"""
return self.first + self.__overall_duration
def __init__(
self,
*,
# current date
today: date,
# month/day when events begin
begin_month: int,
begin_day: int,
# events: e.g. a 2 means there is an event on the 2nd day
# i.e. 1 day after begin
# - assume sorted (ascending)
events: list[int],
# countdown to closing begins after last event
close_after: int,
) -> None:
# account for the last event, then add closing period
self.__overall_duration = timedelta(days=events[-1] - 1 + close_after)
# the events may begin last year, this year or next year
maybe_begin = (
datetime(today.year + year_diff, begin_month, begin_day).date()
for year_diff in (-1, 0, +1)
)
# find the first begin where the end date is in the future
begin = next(
begin for begin in maybe_begin if today <= (begin + self.__overall_duration)
)
# all event dates
self.dates = {event: begin + timedelta(days=event - 1) for event in events}

View file

@ -0,0 +1,100 @@
from typing import TypeVar
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
T = TypeVar("T")
class Credentials(BaseModel):
username: str = ""
password: str = ""
class DavSettings(BaseModel):
"""
Connection to a DAV server.
"""
protocol: str = "https"
host: str = "example.com"
path: str = "/remote.php/webdav"
prefix: str = "/advent22"
auth: Credentials = Credentials(
username="advent22_user",
password="password",
)
cache_ttl: int = 60 * 10
config_filename: str = "config.toml"
@property
def url(self) -> str:
"""
Combined DAV URL.
"""
return f"{self.protocol}://{self.host}{self.path}{self.prefix}"
class RedisSettings(BaseModel):
"""
Connection to a redis server.
"""
host: str = "localhost"
port: int = 6379
db: int = 0
protocol: int = 3
class Settings(BaseSettings):
"""
Per-run settings.
"""
model_config = SettingsConfigDict(
env_file="api.conf",
env_file_encoding="utf-8",
env_nested_delimiter="__",
)
#####
# general settings
#####
production_mode: bool = False
ui_directory: str = "/usr/local/share/advent22_ui/html"
#####
# openapi settings
#####
def __dev_value(self, value: T) -> T | None:
if self.production_mode:
return None
return value
@property
def openapi_url(self) -> str | None:
return self.__dev_value("/api/openapi.json")
@property
def docs_url(self) -> str | None:
return self.__dev_value("/api/docs")
@property
def redoc_url(self) -> str | None:
return self.__dev_value("/api/redoc")
#####
# webdav settings
#####
webdav: DavSettings = DavSettings()
redis: RedisSettings = RedisSettings()
SETTINGS = Settings()

View file

@ -0,0 +1,96 @@
import re
from enum import Enum
from random import Random
from pydantic import BaseModel, field_validator
RE_WHITESPACE = re.compile(
pattern=r"\s+",
flags=re.UNICODE | re.IGNORECASE,
)
RE_SPECIAL_CHARS = re.compile(
pattern=r"[^a-zA-Z0-9\s]+",
flags=re.UNICODE | re.IGNORECASE,
)
class TransformedString(BaseModel):
class __Whitespace(str, Enum):
# unverändert
KEEP = "KEEP"
# Leerzeichen an Anfang und Ende entfernen
STRIP = "STRIP"
# whitespace durch Leerzeichen ersetzen
SPACE = "SPACE"
# whitespace entfernen
REMOVE = "REMOVE"
class __SpecialChars(str, Enum):
# unverändert
KEEP = "KEEP"
# Sonderzeichen entfernen
REMOVE = "REMOVE"
class __Case(str, Enum):
# unverändert
KEEP = "KEEP"
# GROSSBUCHSTABEN
UPPER = "UPPER"
# kleinbuchstaben
LOWER = "LOWER"
# ZuFÄllIg
RANDOM = "RANDOM"
value: str
whitespace: __Whitespace = __Whitespace.REMOVE
special_chars: __SpecialChars = __SpecialChars.REMOVE
case: __Case = __Case.UPPER
@field_validator("whitespace", "case", mode="before")
def transform_from_str(cls, v) -> str:
return str(v).upper()
@property
def clean(self) -> str:
result = self.value
# Whitespace verarbeiten
if self.whitespace is TransformedString.__Whitespace.STRIP:
result = result.strip()
elif self.whitespace is TransformedString.__Whitespace.SPACE:
result = RE_WHITESPACE.sub(string=result, repl=" ")
elif self.whitespace is TransformedString.__Whitespace.REMOVE:
result = RE_WHITESPACE.sub(string=result, repl="")
# Sonderzeichen verarbeiten
if self.special_chars is TransformedString.__SpecialChars.REMOVE:
result = RE_SPECIAL_CHARS.sub(string=result, repl="")
# Groß-/Kleinschreibung verarbeiten
if self.case is TransformedString.__Case.UPPER:
result = result.upper()
elif self.case is TransformedString.__Case.LOWER:
result = result.lower()
elif self.case is TransformedString.__Case.RANDOM:
rnd = Random(self.value)
def randomcase(c: str) -> str:
if rnd.choice((True, False)):
return c.upper()
return c.lower()
result = "".join(randomcase(c) for c in result)
return result

22
api/advent22_api/main.py Normal file
View file

@ -0,0 +1,22 @@
#!/usr/bin/python3
import uvicorn
from .core.settings import SETTINGS
def main() -> None:
"""
If the `main` script is run, `uvicorn` is used to run the app.
"""
uvicorn.run(
app="advent22_api.app:app",
host="0.0.0.0",
port=8000,
reload=not SETTINGS.production_mode,
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,8 @@
from fastapi import APIRouter
from . import admin, user
router = APIRouter(prefix="/api")
router.include_router(admin.router)
router.include_router(user.router)

View file

@ -0,0 +1,65 @@
import secrets
from datetime import date
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from ..core.config import Config, get_config
from ..core.depends import get_all_event_dates
from ..core.helpers import EventDates
security = HTTPBasic()
async def user_is_admin(
credentials: HTTPBasicCredentials = Depends(security),
cfg: Config = Depends(get_config),
) -> bool:
"""
True iff der user "admin" ist
"""
username_correct = secrets.compare_digest(
credentials.username.lower(),
cfg.admin.username.lower(),
)
password_correct = secrets.compare_digest(
credentials.password,
cfg.admin.password,
)
return username_correct and password_correct
async def require_admin(
is_admin: bool = Depends(user_is_admin),
) -> None:
"""
HTTP 401 iff der user nicht "admin" ist
"""
if not is_admin:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wie unhöflich!!!")
async def user_visible_days(
event_dates: EventDates = Depends(get_all_event_dates),
) -> list[int]:
"""
User-sichtbare Türchen
"""
today = date.today()
return [event for event, date in event_dates.dates.items() if date <= today]
async def user_can_view_day(
day: int,
visible_days: list[int] = Depends(user_visible_days),
) -> bool:
"""
True iff das Türchen von Tag `day` user-sichtbar ist
"""
return day in visible_days

View file

@ -0,0 +1,189 @@
from datetime import date
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from advent22_api.core.helpers import EventDates
from ..core.calendar_config import (
CalendarConfig,
DoorsSaved,
get_calendar_config,
)
from ..core.config import Config, Image, get_config
from ..core.depends import (
TTFont,
get_all_event_dates,
get_all_image_names,
get_all_parts,
get_all_ttfonts,
)
from ..core.settings import SETTINGS, Credentials, RedisSettings
from ._security import require_admin, user_is_admin
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/is_admin")
async def is_admin(
is_admin: bool = Depends(user_is_admin),
) -> bool:
return is_admin
class AdminConfigModel(BaseModel):
class __Solution(BaseModel):
value: str
whitespace: str
special_chars: str
case: str
clean: str
class __Puzzle(BaseModel):
first: date
next: date | None
last: date
end: date
seed: str
extra_days: list[int]
skip_empty: bool
class __Calendar(BaseModel):
config_file: str
background: str
favicon: str
class __Font(BaseModel):
file: str
size: int
class __WebDAV(BaseModel):
url: str
cache_ttl: int
config_file: str
solution: __Solution
puzzle: __Puzzle
calendar: __Calendar
image: Image
fonts: list[__Font]
redis: RedisSettings
webdav: __WebDAV
@router.get("/config_model")
async def get_config_model(
_: None = Depends(require_admin),
cfg: Config = Depends(get_config),
cal_cfg: CalendarConfig = Depends(get_calendar_config),
event_dates: EventDates = Depends(get_all_event_dates),
ttfonts: list[TTFont] = Depends(get_all_ttfonts),
) -> AdminConfigModel:
"""
Kombiniert aus privaten `settings`, `config` und `calendar_config`
"""
return AdminConfigModel.model_validate(
{
"solution": {
"value": cfg.solution.value,
"whitespace": cfg.solution.whitespace,
"special_chars": cfg.solution.special_chars,
"case": cfg.solution.case,
"clean": cfg.solution.clean,
},
"puzzle": {
"first": event_dates.first,
"next": event_dates.next,
"last": event_dates.last,
"end": event_dates.end,
"seed": cfg.random_seed,
"extra_days": sorted(cfg.puzzle.extra_days),
"skip_empty": cfg.puzzle.skip_empty,
},
"calendar": {
"config_file": cfg.calendar,
"background": cal_cfg.background,
"favicon": cal_cfg.favicon,
},
"image": cfg.image,
"fonts": [
{"file": ttfont.file_name, "size": ttfont.size} for ttfont in ttfonts
],
"redis": SETTINGS.redis,
"webdav": {
"url": SETTINGS.webdav.url,
"cache_ttl": SETTINGS.webdav.cache_ttl,
"config_file": SETTINGS.webdav.config_filename,
},
}
)
@router.get("/day_image_names")
async def get_day_image_names(
_: None = Depends(require_admin),
image_names: dict[int, str] = Depends(get_all_image_names),
) -> dict[int, str]:
"""
Zuordnung der verwendeten Bilder zu den Tagen
"""
return image_names
@router.get("/day_parts")
async def get_day_parts(
_: None = Depends(require_admin),
parts: dict[int, str] = Depends(get_all_parts),
) -> dict[int, str]:
"""
Zuordnung der Lösungsteile zu den Tagen
"""
return parts
@router.get("/doors")
async def get_doors(
_: None = Depends(require_admin),
cal_cfg: CalendarConfig = Depends(get_calendar_config),
) -> DoorsSaved:
"""
Türchen lesen
"""
return cal_cfg.doors
@router.put("/doors")
async def put_doors(
doors: DoorsSaved,
_: None = Depends(require_admin),
cfg: Config = Depends(get_config),
cal_cfg: CalendarConfig = Depends(get_calendar_config),
) -> None:
"""
Türchen ändern
"""
cal_cfg.doors = sorted(
doors,
key=lambda door: door.day,
)
await cal_cfg.change(cfg)
@router.get("/credentials/{name}")
async def get_credentials(
name: str,
_: None = Depends(require_admin),
cfg: Config = Depends(get_config),
) -> Credentials:
if name == "dav":
return SETTINGS.webdav.auth
elif name == "ui":
return cfg.admin
else:
return Credentials()

View file

@ -0,0 +1,109 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from PIL.Image import Image
from ..core.calendar_config import (
CalendarConfig,
DoorsSaved,
get_calendar_config,
)
from ..core.config import Config, Site, get_config
from ..core.depends import get_all_event_dates, get_day_image
from ..core.helpers import (
EventDates,
ImageData,
api_return_ico,
api_return_jpeg,
load_image,
)
from ._security import user_can_view_day, user_is_admin, user_visible_days
router = APIRouter(prefix="/user", tags=["user"])
@router.get("/background_image")
async def get_background_image(
cal_cfg: CalendarConfig = Depends(get_calendar_config),
) -> ImageData:
"""
Hintergrundbild laden
"""
return await api_return_jpeg(await load_image(f"files/{cal_cfg.background}"))
@router.get("/favicon")
async def get_favicon(
cal_cfg: CalendarConfig = Depends(get_calendar_config),
) -> ImageData:
"""
Favicon laden
"""
try:
return await api_return_ico(await load_image(f"files/{cal_cfg.favicon}"))
except RuntimeError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@router.get("/site_config")
async def get_site_config(
cfg: Config = Depends(get_config),
) -> Site:
"""
Seiteninhalt
"""
return cfg.site
@router.get("/doors")
async def get_doors(
cal_cfg: CalendarConfig = Depends(get_calendar_config),
visible_days: list[int] = Depends(user_visible_days),
) -> DoorsSaved:
"""
User-sichtbare Türchen lesen
"""
return [door for door in cal_cfg.doors if door.day in visible_days]
@router.get("/image_{day}")
async def get_image_for_day(
user_can_view: bool = Depends(user_can_view_day),
is_admin: bool = Depends(user_is_admin),
image: Image | None = Depends(get_day_image),
) -> ImageData:
"""
Bild für einen Tag erstellen
"""
if not (user_can_view or is_admin):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wie unhöflich!!!")
if image is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Ich habe heute leider kein Foto für dich."
)
return await api_return_jpeg(image)
@router.get("/next_door")
async def get_next_door(
event_dates: EventDates = Depends(get_all_event_dates),
) -> int | None:
"""
Zeit in ms, bis das nächste Türchen öffnet
"""
if event_dates.next is None:
return None
dt = datetime.combine(event_dates.next, datetime.min.time())
td = dt - datetime.now()
return int(td.total_seconds() * 1000)

1876
api/poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

33
api/pyproject.toml Normal file
View file

@ -0,0 +1,33 @@
[tool.poetry]
authors = [
"Jörn-Michael Miehe <jmm@yavook.de>",
"Penner42 <unbekannt42@web.de>",
]
description = ""
license = "MIT"
name = "advent22_api"
version = "0.1.0"
[tool.poetry.dependencies]
Pillow = "^12.1.1"
asyncify = "^0.12.1"
cachetools = "^7.0.1"
cachetoolsutils = "^11.0"
fastapi = "^0.129.0"
markdown = "^3.10.2"
numpy = "^2.4.2"
pydantic-settings = "^2.13.0"
python = ">=3.11,<3.15"
redis = {extras = ["hiredis"], version = "^7.1.1"}
tomli-w = "^1.2.0"
uvicorn = {extras = ["standard"], version = "^0.40.0"}
webdavclient3 = "^3.14.7"
[tool.poetry.group.dev.dependencies]
black = "^26.1.0"
flake8 = "^7.3.0"
pytest = "^9.0.2"
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core>=1.0.0"]

View file

@ -0,0 +1,88 @@
from datetime import date
from advent22_api.core.helpers import EventDates
def test_get_before():
today = date(2023, 11, 30)
ed = EventDates(
today=today,
begin_month=12,
begin_day=1,
events=list(range(1, 25)),
close_after=5,
)
assert ed.first == date(2023, 12, 1)
assert ed.get_next(today=today) == date(2023, 12, 1)
assert ed.last == date(2023, 12, 24)
assert ed.end == date(2023, 12, 29)
def test_get_after():
today = date(2023, 12, 30)
ed = EventDates(
today=today,
begin_month=12,
begin_day=1,
events=list(range(1, 25)),
close_after=5,
)
assert ed.first == date(2024, 12, 1)
assert ed.get_next(today=today) == date(2024, 12, 1)
assert ed.last == date(2024, 12, 24)
assert ed.end == date(2024, 12, 29)
def test_get_during_events():
today = date(2023, 12, 10)
ed = EventDates(
today=today,
begin_month=12,
begin_day=1,
events=list(range(1, 25)),
close_after=5,
)
assert ed.first == date(2023, 12, 1)
assert ed.get_next(today=today) == date(2023, 12, 11)
assert ed.last == date(2023, 12, 24)
assert ed.end == date(2023, 12, 29)
def test_get_during_closing():
today = date(2023, 12, 29)
ed = EventDates(
today=today,
begin_month=12,
begin_day=1,
events=list(range(1, 25)),
close_after=5,
)
assert ed.first == date(2023, 12, 1)
assert ed.get_next(today=today) is None
assert ed.last == date(2023, 12, 24)
assert ed.end == date(2023, 12, 29)
def test_get_during_wrap():
today = date(2024, 1, 1)
ed = EventDates(
today=today,
begin_month=12,
begin_day=1,
events=list(range(1, 25)),
close_after=8,
)
assert ed.first == date(2023, 12, 1)
assert ed.get_next(today=today) is None
assert ed.last == date(2023, 12, 24)
assert ed.end == date(2024, 1, 1)

77
api/test/test_spread.py Normal file
View file

@ -0,0 +1,77 @@
from advent22_api.core.helpers import spread
def test_easy() -> None:
assert spread([1, 4], 0) == []
assert spread([1, 4], 1) == [2]
assert spread([1, 4], 2) == [2, 3]
assert spread([1, 4], 5) == [2, 3, 1, 2, 3]
assert spread([1, 4], 10) == [2, 3, 1, 2, 3, 4, 1, 2, 3, 4]
def test_tight() -> None:
assert spread([1, 2], 0) == []
assert spread([1, 2], 1) == [1]
assert spread([1, 2], 2) == [1, 2]
assert spread([1, 2], 5) == [1, 2, 1, 2, 1]
assert spread([1, 2], 10) == [1, 2, 1, 2, 1, 2, 1, 2, 1, 2]
assert spread([1, 2, 3, 4, 5], 0) == []
assert spread([1, 2, 3, 4, 5], 1) == [1]
assert spread([1, 2, 3, 4, 5], 2) == [1, 2]
assert spread([1, 2, 3, 4, 5], 5) == [1, 2, 3, 4, 5]
assert spread([1, 2, 3, 4, 5], 10) == [1, 2, 3, 4, 5, 1, 2, 3, 4, 5]
def test_more_given() -> None:
assert spread([0, 5, 10], 0) == []
assert spread([0, 5, 10], 1) == [1]
assert spread([0, 5, 10], 2) == [1, 2]
assert spread([0, 5, 10], 5) == [1, 2, 3, 4, 6]
assert spread([0, 5, 10], 10) == [1, 2, 3, 4, 6, 7, 8, 9, 0, 1]
assert spread([0, 1, 2, 5, 10], 0) == []
assert spread([0, 1, 2, 5, 10], 1) == [3]
assert spread([0, 1, 2, 5, 10], 2) == [3, 4]
assert spread([0, 1, 2, 5, 10], 5) == [3, 4, 6, 7, 8]
assert spread([0, 1, 2, 5, 10], 10) == [3, 4, 6, 7, 8, 9, 0, 1, 2, 3]
def test_one_given() -> None:
assert spread([0], 0) == []
assert spread([0], 1) == [1]
assert spread([0], 2) == [1, 2]
assert spread([0], 5) == [1, 2, 3, 4, 5]
assert spread([0], 10) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
assert spread([1], 0) == []
assert spread([1], 1) == [1]
assert spread([1], 2) == [1, 1]
assert spread([1], 5) == [1, 1, 1, 1, 1]
assert spread([1], 10) == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
assert spread([2], 0) == []
assert spread([2], 1) == [1]
assert spread([2], 2) == [1, 1]
assert spread([2], 5) == [1, 1, 2, 1, 2]
assert spread([2], 10) == [1, 1, 2, 1, 2, 1, 2, 1, 2, 1]
assert spread([5], 0) == []
assert spread([5], 1) == [1]
assert spread([5], 2) == [1, 2]
assert spread([5], 5) == [1, 2, 3, 4, 1]
assert spread([5], 10) == [1, 2, 3, 4, 1, 2, 3, 4, 5, 1]
assert spread([10], 0) == []
assert spread([10], 1) == [1]
assert spread([10], 2) == [1, 2]
assert spread([10], 5) == [1, 2, 3, 4, 5]
assert spread([10], 10) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 1]
def test_none_given() -> None:
assert spread([], 0) == []
assert spread([], 1) == [1]
assert spread([], 2) == [1, 2]
assert spread([], 5) == [1, 2, 3, 4, 5]
assert spread([], 10) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

61
scripts/check_version Executable file
View file

@ -0,0 +1,61 @@
#!/bin/sh
script="$( readlink -f "${0}" )"
script_dir="$( dirname "${script}" )"
git rev-parse --abbrev-ref HEAD | grep -E '^develop$|^feature/' >/dev/null \
&& git_status="developing"
git rev-parse --abbrev-ref HEAD | grep -E '^release/|^hotfix/' >/dev/null \
&& git_status="releasing"
git rev-parse --abbrev-ref HEAD | grep -E '^master$' >/dev/null \
&& git_status="released"
if [ "${git_status}" = "developing" ]; then
echo "Status: Developing"
# => version from most recent tag
git_version="$( \
git describe --tags --abbrev=0 \
| sed -E 's/^v[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9]).*$/\1/'
)"
elif [ "${git_status}" = "releasing" ]; then
echo "Status: Releasing"
# => version from releasing branch
git_version="$( \
git rev-parse --abbrev-ref HEAD \
| cut -d '/' -f 2
)"
elif [ "${git_status}" = "released" ]; then
echo "Status: Released"
# => version from current tag
git_version="$( \
git describe --tags \
| sed -E 's/^v[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9])$/\1/'
)"
else
echo "ERROR: Invalid git branch"
echo "ERROR: Chores cannot be run on '$( git rev-parse --abbrev-ref HEAD )'!"
exit 2
fi
api_version="$( \
grep '^version' "${script_dir}/../api/pyproject.toml" \
| sed -E 's/^version[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9]).*$/\1/'
)"
ui_version="$( \
grep '"version":' "${script_dir}/../ui/package.json" \
| sed -E 's/.*"version":[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9]).*$/\1/'
)"
if [ "${git_version}" = "${api_version}" ] \
&& [ "${git_version}" = "${ui_version}" ]; then
mark="✅️"
else
mark="❌️"
fi
echo "git: ${git_version}, api: ${api_version}, ui: ${ui_version}"
echo ">>>>> RESULT: ${mark} <<<<<"
[ "${mark}" = "✅️" ] || exit 1

View file

@ -0,0 +1,67 @@
import json
import multiprocessing
import os
workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1")
max_workers_str = os.getenv("MAX_WORKERS")
use_max_workers = None
if max_workers_str:
use_max_workers = int(max_workers_str)
web_concurrency_str = os.getenv("WEB_CONCURRENCY", None)
host = os.getenv("HOST", "0.0.0.0")
port = os.getenv("PORT", "80")
bind_env = os.getenv("BIND", None)
use_loglevel = os.getenv("LOG_LEVEL", "info")
if bind_env:
use_bind = bind_env
else:
use_bind = f"{host}:{port}"
cores = multiprocessing.cpu_count()
workers_per_core = float(workers_per_core_str)
default_web_concurrency = workers_per_core * cores
if web_concurrency_str:
web_concurrency = int(web_concurrency_str)
assert web_concurrency > 0
else:
web_concurrency = max(int(default_web_concurrency), 2)
if use_max_workers:
web_concurrency = min(web_concurrency, use_max_workers)
accesslog_var = os.getenv("ACCESS_LOG", "-")
use_accesslog = accesslog_var or None
errorlog_var = os.getenv("ERROR_LOG", "-")
use_errorlog = errorlog_var or None
graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "120")
timeout_str = os.getenv("TIMEOUT", "120")
keepalive_str = os.getenv("KEEP_ALIVE", "5")
# Gunicorn config variables
loglevel = use_loglevel
workers = web_concurrency
bind = use_bind
errorlog = use_errorlog
worker_tmp_dir = "/dev/shm"
accesslog = use_accesslog
graceful_timeout = int(graceful_timeout_str)
timeout = int(timeout_str)
keepalive = int(keepalive_str)
# For debugging and testing
log_data = {
"loglevel": loglevel,
"workers": workers,
"bind": bind,
"graceful_timeout": graceful_timeout,
"timeout": timeout,
"keepalive": keepalive,
"errorlog": errorlog,
"accesslog": accesslog,
# Additional, non-gunicorn variables
"workers_per_core": workers_per_core,
"use_max_workers": use_max_workers,
"host": host,
"port": port,
}
print(json.dumps(log_data))

View file

@ -0,0 +1,20 @@
#!/bin/sh
set -e
MODULE_NAME=${MODULE_NAME:-"app.main"}
VARIABLE_NAME=${VARIABLE_NAME:-"app"}
export APP_MODULE="${APP_MODULE:-"$MODULE_NAME:$VARIABLE_NAME"}"
export GUNICORN_CONF="${GUNICORN_CONF:-"/usr/local/share/uvicorn-gunicorn/gunicorn_conf.py"}"
export WORKER_CLASS="${WORKER_CLASS:-"uvicorn.workers.UvicornWorker"}"
if [ -f "${PRE_START_PATH}" ] ; then
echo "Running script ${PRE_START_PATH}"
# shellcheck disable=SC1090
. "${PRE_START_PATH}"
fi
# Start Gunicorn
exec gunicorn \
-k "${WORKER_CLASS}" \
-c "${GUNICORN_CONF}" \
"${APP_MODULE}"

22
scripts/publish Executable file
View file

@ -0,0 +1,22 @@
#!/bin/bash
script="$( readlink -f "${0}" )"
script_dir="$( dirname "${script}" )"
# shellcheck disable=SC1091
. "${script_dir}/check_version"
# vars defined in `check_version` script
# shellcheck disable=SC2154
if [ "${git_status}" = "releasing" ] || [ "${git_status}" = "released" ]; then
# shellcheck disable=SC2154
image_tag="${git_version}"
else
image_tag="latest"
fi
docker buildx build \
--pull --push \
--tag "code.lenaisten.de/lenaisten/advent22:${image_tag}" \
--platform "linux/amd64" \
"${script_dir}/.."

4
ui/.browserslistrc Normal file
View file

@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

View file

@ -0,0 +1,48 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/javascript-node
{
"name": "Advent22 UI",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/javascript-node:4-24-trixie",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/git-lfs:1": {},
"ghcr.io/devcontainers-extra/features/apt-get-packages:1": {
"packages": "git-flow"
},
"ghcr.io/devcontainers-extra/features/vue-cli:2": {}
},
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Set *default* container specific settings.json values on container create.
"settings": {
"terminal.integrated.defaultProfile.linux": "zsh"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"mhutchie.git-graph",
"Syler.sass-indented",
"Vue.volar"
]
}
},
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Use 'postStartCommand' to run commands after the container is started.
"postStartCommand": "yarn dlx update-browserslist-db@latest && yarn install"
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

38
ui/.eslintrc.js Normal file
View file

@ -0,0 +1,38 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/typescript/recommended",
],
parserOptions: {
ecmaVersion: 2020,
},
rules: {
"no-empty": "off",
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
},
overrides: [
{
files: [
"**/__tests__/*.{j,t}s?(x)",
"**/tests/unit/**/*.spec.{j,t}s?(x)",
],
env: {
mocha: true,
},
rules: {
"@typescript-eslint/no-unused-expressions": "off",
}
},
],
};

149
ui/.gitignore vendored Normal file
View file

@ -0,0 +1,149 @@
.DS_Store
# https://raw.githubusercontent.com/github/gitignore/refs/heads/main/Node.gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# pnpm
.pnpm-store
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/

3
ui/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["sdras.vue-vscode-snippets"]
}

15
ui/.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,15 @@
{
// Verwendet IntelliSense zum Ermitteln möglicher Attribute.
// Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen.
// Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Chrome mit Advent22 UI starten",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
}
]
}

23
ui/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,23 @@
{
"[scss][vue][typescript][javascript][json][jsonc][jsonl]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.formatOnSave": false,
},
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"git.closeDiffOnOperation": true,
"editor.formatOnSave": true,
"editor.tabSize": 2,
"sass.disableAutoIndent": true,
"sass.format.convert": false,
"sass.format.deleteWhitespace": true,
"prettier.trailingComma": "all",
}

12
ui/.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,12 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "serve",
"problemMatcher": [],
"label": "UI starten",
"detail": "vue-cli-service serve"
}
]
}

942
ui/.yarn/releases/yarn-4.12.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

2
ui/.yarnrc.yml Normal file
View file

@ -0,0 +1,2 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.12.0.cjs

29
ui/README.md Normal file
View file

@ -0,0 +1,29 @@
# advent22_ui
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn serve
```
### Compiles and minifies for production
```
yarn build
```
### Lints and fixes files
```
yarn lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
ui/babel.config.json Normal file
View file

@ -0,0 +1,5 @@
{
"presets": [
"@vue/cli-plugin-babel/preset"
]
}

47
ui/package.json Normal file
View file

@ -0,0 +1,47 @@
{
"name": "advent22_ui",
"version": "0.1.0",
"private": true,
"packageManager": "yarn@4.12.0",
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit",
"test:unit-watch": "vue-cli-service test:unit --watch",
"lint": "vue-cli-service lint",
"ui": "vue ui --host 0.0.0.0 --headless"
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/vue-fontawesome": "^3.1.3",
"@types/chai": "^5.2.3",
"@types/luxon": "^3.7.1",
"@types/mocha": "^10.0.10",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.55.0",
"@vue/cli-plugin-babel": "^5.0.9",
"@vue/cli-plugin-eslint": "^5.0.9",
"@vue/cli-plugin-typescript": "^5.0.9",
"@vue/cli-plugin-unit-mocha": "^5.0.9",
"@vue/cli-service": "^5.0.9",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"animate.css": "^4.1.1",
"axios": "^1.13.5",
"bulma": "^1.0.4",
"bulma-toast": "2.4.3",
"chai": "^6.2.2",
"core-js": "^3.48.0",
"eslint": "^8.57.1",
"eslint-plugin-vue": "^9.33.0",
"luxon": "^3.7.2",
"pinia": "^3.0.4",
"sass": "~1.94.3",
"sass-loader": "^16.0.0",
"typescript": "^5.9.3",
"vue": "^3.5.25",
"vue-cli-plugin-webpack-bundle-analyzer": "^4.0.0"
}
}

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

40
ui/public/index.html Normal file
View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<title><%= htmlWebpackPlugin.options.title %></title>
<!-- Matomo -->
<script>
let _paq = (window._paq = window._paq || []);
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
(function () {
const u = "https://stats.kiwi.lenaisten.de/";
_paq.push(["setTrackerUrl", u + "matomo.php"]);
_paq.push(["setSiteId", "10"]);
const d = document,
g = d.createElement("script"),
s = d.getElementsByTagName("script")[0];
g.async = true;
g.src = u + "matomo.js";
s.parentNode.insertBefore(g, s);
})();
</script>
<!-- End Matomo Code -->
</head>
<body>
<noscript>
<strong
>Es tut uns leid, aber <%= htmlWebpackPlugin.options.title %>
funktioniert nicht richtig ohne JavaScript. Bitte aktivieren Sie es, um
fortzufahren.</strong
>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

67
ui/src/App.vue Normal file
View file

@ -0,0 +1,67 @@
<template>
<section class="hero is-small is-primary">
<div class="hero-body">
<h1 class="title is-uppercase">{{ store.site_config.title }}</h1>
<h2 class="subtitle">{{ store.site_config.subtitle }}</h2>
</div>
</section>
<section class="section px-3">
<progress
v-if="store.background_image === 'loading'"
class="progress is-primary"
max="100"
/>
<div
v-else-if="store.background_image === 'error'"
class="notification is-danger"
>
Hintergrundbild konnte nicht geladen werden
</div>
<div v-else class="container">
<AdminView v-if="store.is_admin" />
<UserView v-else />
</div>
</section>
<div class="is-flex-grow-1" />
<footer class="footer">
<div class="level">
<div class="level-item">
<p v-html="store.site_config.footer" />
</div>
<div class="level-right">
<div class="level-item">
<TouchButton class="is-small is-warning" />
</div>
<div class="level-item">
<AdminButton class="is-small is-link is-outlined" />
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
import { advent22Store } from "./lib/store";
import AdminView from "./components/admin/AdminView.vue";
import AdminButton from "./components/AdminButton.vue";
import TouchButton from "./components/TouchButton.vue";
import UserView from "./components/UserView.vue";
const store = advent22Store();
</script>
<style>
html {
overflow-y: auto !important;
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
</style>

BIN
ui/src/assets/logo.png (Stored with Git LFS) Normal file

Binary file not shown.

14
ui/src/bulma-scheme.scss Normal file
View file

@ -0,0 +1,14 @@
@charset "utf-8";
//=====================
// custom color scheme
//=====================
$colors: (
"primary": #945de1,
"link": #64b4bd,
"info": #8c4e80,
"success": #7e8e2b,
"warning": #f6ca6b,
"danger": #c5443b,
);

View file

@ -0,0 +1,52 @@
<template>
<LoginModal v-if="modal_visible" @submit="on_submit" @cancel="on_cancel" />
<BulmaButton
v-bind="$attrs"
:icon="['fas', store.is_admin ? 'fa-toggle-on' : 'fa-toggle-off']"
:busy="is_busy"
text="Admin"
@click.left="on_click"
/>
</template>
<script setup lang="ts">
import { APIError } from "@/lib/api_error";
import type { Credentials } from "@/lib/model";
import { advent22Store } from "@/lib/store";
import { ref } from "vue";
import BulmaButton from "./bulma/Button.vue";
import LoginModal from "./LoginModal.vue";
const modal_visible = ref(false);
const is_busy = ref(false);
const store = advent22Store();
function on_click(): void {
if (store.is_admin) {
store.logout();
} else {
// show login modal
is_busy.value = true;
modal_visible.value = true;
}
}
async function on_submit(creds: Credentials): Promise<void> {
modal_visible.value = false;
try {
await store.login(creds);
} catch (error) {
APIError.alert(error);
} finally {
is_busy.value = false;
}
}
function on_cancel(): void {
modal_visible.value = false;
is_busy.value = false;
}
</script>

View file

@ -0,0 +1,110 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<MultiModal @handle="on_modal_handle" />
<BulmaToast @handle="on_toast_handle" class="content">
<p>
Du hast noch keine Türchen geöffnet, vielleicht gibt es ein Anzeigeproblem
in Deinem Webbrowser?
</p>
<div class="level">
<div class="level-item">
<BulmaButton
class="is-success"
text="Türchen anzeigen"
@click.left="
store.is_touch_device = true;
toast?.hide();
"
/>
</div>
<div class="level-item">
<BulmaButton
class="is-danger"
text="Ich möchte selbst suchen"
@click.left="toast?.hide()"
/>
</div>
</div>
</BulmaToast>
<figure>
<div class="image is-unselectable">
<img :src="unwrap_loading(store.background_image).data_url" />
<ThouCanvas>
<CalendarDoor
v-for="(door, index) in doors"
:key="`door-${index}`"
:door="door"
:visible="store.is_touch_device"
:title="name_door(door.day)"
@click="door_click(door.day)"
style="cursor: pointer"
/>
</ThouCanvas>
</div>
</figure>
</template>
<script setup lang="ts">
import { API } from "@/lib/api";
import { APIError } from "@/lib/api_error";
import { type VueLike, name_door, unwrap_loading } from "@/lib/helpers";
import type { ImageData } from "@/lib/model";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store";
import { onBeforeUnmount } from "vue";
import MultiModal, { type HMultiModal } from "./MultiModal.vue";
import BulmaButton from "./bulma/Button.vue";
import BulmaToast, { type HBulmaToast } from "./bulma/Toast.vue";
import CalendarDoor from "./calendar/CalendarDoor.vue";
import ThouCanvas from "./calendar/ThouCanvas.vue";
defineProps<{
doors: VueLike<Door>[];
}>();
const store = advent22Store();
let modal: HMultiModal | undefined;
let toast: HBulmaToast | undefined;
let toast_timeout: number | undefined;
function on_modal_handle(handle: HMultiModal): void {
modal = handle;
}
function on_toast_handle(handle: HBulmaToast): void {
toast = handle;
if (store.is_touch_device) return;
store.when_initialized(() => {
toast_timeout = window.setTimeout(() => {
if (store.user_doors.length === 0) return;
if (store.is_touch_device) return;
toast!.show({ duration: 600000, type: "is-warning" });
}, 10e3);
});
}
async function door_click(day: number): Promise<void> {
window.clearTimeout(toast_timeout);
toast?.hide();
if (modal === undefined) return;
modal.show_loading();
try {
const day_image = await API.request<ImageData>(`user/image_${day}`);
modal.show_image(day_image.data_url, name_door(day));
} catch (error) {
APIError.alert(error);
modal.hide();
}
}
onBeforeUnmount(() => toast?.hide());
</script>

View file

@ -0,0 +1,48 @@
<template>
{{ string_repr }}
</template>
<script setup lang="ts">
import { Duration } from "luxon";
import { onBeforeUnmount, onMounted, ref } from "vue";
const props = withDefaults(
defineProps<{
until: number;
tick_time?: number;
}>(),
{ tick_time: 200 },
);
let interval_id: number | undefined;
const string_repr = ref("");
onMounted(() => {
function tick(): void {
const distance_ms = props.until - Date.now();
if (distance_ms <= 0) {
string_repr.value = "Jetzt!";
return;
}
const distance = Duration.fromMillis(distance_ms);
const d_days = distance.shiftTo("day").mapUnits(Math.floor);
const d_hms = distance.minus(d_days).shiftTo("hour", "minute", "second");
if (d_days.days > 0) {
string_repr.value = d_days.toHuman() + " ";
} else {
string_repr.value = "";
}
string_repr.value += d_hms.toFormat("hh:mm:ss");
}
tick();
interval_id = window.setInterval(tick, props.tick_time);
});
onBeforeUnmount(() => {
window.clearInterval(interval_id);
});
</script>

View file

@ -0,0 +1,93 @@
<template>
<div class="modal is-active">
<div class="modal-background" />
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Login</p>
<button class="delete" @click.left="cancel" />
</header>
<section class="modal-card-body">
<div class="field">
<label class="label">Username</label>
<div class="control">
<input
ref="username_input"
class="input"
type="text"
v-model="creds.username"
/>
</div>
</div>
<div class="field">
<label class="label">Passwort</label>
<div class="control">
<input class="input" type="password" v-model="creds.password" />
</div>
</div>
</section>
<footer class="modal-card-foot is-flex is-justify-content-space-around">
<BulmaButton
class="is-success"
@click.left="submit"
:icon="['fas', 'fa-unlock']"
text="Login"
/>
<BulmaButton
class="is-danger"
@click.left="cancel"
:icon="['fas', 'fa-circle-xmark']"
text="Abbrechen"
/>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
import { wait_for } from "@/lib/helpers";
import type { Credentials } from "@/lib/model";
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue";
import BulmaButton from "./bulma/Button.vue";
const username_input = useTemplateRef("username_input");
const emit = defineEmits<{
(event: "submit", creds: Credentials): void;
(event: "cancel"): void;
}>();
const creds = ref<Credentials>({
username: "",
password: "",
});
function submit(): void {
emit("submit", creds.value);
}
function cancel(): void {
emit("cancel");
}
onMounted(() => {
const on_keydown = (e: KeyboardEvent) => {
if (e.key === "Enter") submit();
else if (e.key === "Escape") cancel();
};
window.addEventListener("keydown", on_keydown);
wait_for(
() => username_input.value !== null,
() => username_input.value!.focus(),
);
onBeforeUnmount(() => {
window.removeEventListener("keydown", on_keydown);
});
});
</script>

View file

@ -0,0 +1,79 @@
<template>
<div v-if="state.show !== 'none'" class="modal is-active" @click="dismiss()">
<div class="modal-background" />
<div class="modal-content" style="max-height: 100vh; max-width: 95vw">
<template v-if="state.show === 'loading'">
<progress class="progress is-primary" max="100" />
</template>
<template v-else-if="state.show === 'image'">
<figure>
<figcaption class="tag is-primary">
{{ state.caption }}
</figcaption>
<div class="image is-square">
<img :src="state.src" alt="Kalender-Bild" />
</div>
</figure>
</template>
</div>
<button
v-if="state.show !== 'loading'"
class="modal-close is-large has-background-primary"
/>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
type ModalState =
| { show: "none" }
| { show: "loading" }
| { show: "image"; src: string; caption: string };
const state = ref<ModalState>({ show: "none" });
export type HMultiModal = {
show_image(src: string, caption: string): void;
show_loading(): void;
hide(): void;
};
const emit = defineEmits<{
(event: "handle", handle: HMultiModal): void;
}>();
function hide(): void {
state.value = { show: "none" };
}
function dismiss(): void {
if (state.value.show !== "loading") {
hide();
}
}
onMounted(() => {
emit("handle", {
show_image(src: string, caption: string = ""): void {
state.value = { show: "image", src: src, caption: caption };
},
show_loading(): void {
state.value = { show: "loading" };
},
hide,
});
const on_keydown = (e: KeyboardEvent) => {
if (e.key === "Escape") dismiss();
};
window.addEventListener("keydown", on_keydown);
onBeforeUnmount(() => {
window.removeEventListener("keydown", on_keydown);
});
});
</script>

View file

@ -0,0 +1,17 @@
<template>
<span>Eingabemodus:&nbsp;</span>
<BulmaButton
v-bind="$attrs"
:icon="['fas', store.is_touch_device ? 'hand-pointer' : 'arrow-pointer']"
:text="store.is_touch_device ? 'Touch' : 'Desktop'"
@click.left="store.toggle_touch_device"
/>
</template>
<script setup lang="ts">
import { advent22Store } from "@/lib/store";
import BulmaButton from "./bulma/Button.vue";
const store = advent22Store();
</script>

View file

@ -0,0 +1,29 @@
<template>
<Calendar :doors="store.user_doors" />
<hr />
<div class="content" v-html="store.site_config.content" />
<div class="content has-text-primary">
<template v-if="store.next_door_target === null">
Alle {{ store.user_doors.length }} Türchen offen!
</template>
<template v-else>
<template v-if="store.user_doors.length === 0">
Zeit bis zum ersten Türchen:
</template>
<template v-else>
{{ store.user_doors.length }} Türchen offen. Zeit bis zum nächsten
Türchen:
</template>
<CountDown :until="store.next_door_target" />
</template>
</div>
</template>
<script setup lang="ts">
import { advent22Store } from "@/lib/store";
import Calendar from "./Calendar.vue";
import CountDown from "./CountDown.vue";
const store = advent22Store();
</script>

View file

@ -0,0 +1,20 @@
<template>
<ConfigView />
<CalendarAssistant />
<DoorMapEditor />
<BulmaDrawer header="Vorschau" :opening="store.update" refreshable>
<UserView />
</BulmaDrawer>
</template>
<script setup lang="ts">
import { advent22Store } from "@/lib/store";
import UserView from "../UserView.vue";
import BulmaDrawer from "../bulma/Drawer.vue";
import CalendarAssistant from "./CalendarAssistant.vue";
import ConfigView from "./ConfigView.vue";
import DoorMapEditor from "./DoorMapEditor.vue";
const store = advent22Store();
</script>

View file

@ -0,0 +1,100 @@
<template>
<MultiModal @handle="on_modal_handle" />
<BulmaDrawer header="Kalender-Assistent" :opening="on_open" refreshable>
<div class="card-content">
<div class="content">
<p>Hervorgehobenen Tagen wurde kein Buchstabe zugewiesen.</p>
<h3>Zuordnung Buchstaben</h3>
<div class="tags are-medium">
<template v-for="(data, day) in day_data" :key="`part-${day}`">
<span v-if="data.part === ''" class="tag is-warning">
{{ day }}
</span>
<span v-else class="tag is-info">
{{ day }}: {{ data.part.split("").join(", ") }}
</span>
</template>
</div>
<h3>Zuordnung Bilder</h3>
<div class="tags are-medium">
<span
v-for="(data, day) in day_data"
:key="`image-${day}`"
:class="'tag is-' + (data.part === '' ? 'warning' : 'primary')"
>
{{ day }}: {{ data.image_name }}
</span>
</div>
<h3>Alle Türchen</h3>
<div class="tags are-medium">
<BulmaButton
v-for="(data, day) in day_data"
:key="`btn-${day}`"
:class="'tag is-' + (data.part === '' ? 'warning' : 'info')"
:icon="['fas', 'fa-door-open']"
:text="day.toString()"
@click.left="door_click(Number(day))"
/>
</div>
</div>
</div>
</BulmaDrawer>
</template>
<script setup lang="ts">
import { API } from "@/lib/api";
import { name_door, objForEach } from "@/lib/helpers";
import type { ImageData, NumStrDict } from "@/lib/model";
import { ref } from "vue";
import MultiModal, { type HMultiModal } from "../MultiModal.vue";
import BulmaButton from "../bulma/Button.vue";
import BulmaDrawer from "../bulma/Drawer.vue";
const day_data = ref<Record<number, { part: string; image_name: string }>>({});
let modal: HMultiModal | undefined;
function on_modal_handle(handle: HMultiModal): void {
modal = handle;
}
async function on_open(): Promise<void> {
const [day_parts, day_image_names] = await Promise.all([
API.request<NumStrDict>("admin/day_parts"),
API.request<NumStrDict>("admin/day_image_names"),
]);
const _ensure_day_in_data = (day: number) => {
if (!(day in day_data.value)) {
day_data.value[day] = { part: "", image_name: "" };
}
};
objForEach(day_parts, (day, part) => {
_ensure_day_in_data(day);
day_data.value[day].part = part;
});
objForEach(day_image_names, (day, image_name) => {
_ensure_day_in_data(day);
day_data.value[day].image_name = image_name;
});
}
async function door_click(day: number): Promise<void> {
if (modal === undefined) return;
modal.show_loading();
try {
const day_image = await API.request<ImageData>(`user/image_${day}`);
modal.show_image(day_image.data_url, name_door(day));
} catch {
modal.hide();
}
}
</script>

View file

@ -0,0 +1,301 @@
<template>
<BulmaDrawer header="Konfiguration" :opening="on_open" refreshable>
<div class="card-content">
<div class="columns">
<div class="column is-one-third">
<div class="content">
<h3>Lösung</h3>
<dl>
<dt>Wert</dt>
<dd>
Eingabe:
<span class="is-family-monospace">
"{{ admin_config_model.solution.value }}"
</span>
</dd>
<dd>
Ausgabe:
<span class="is-family-monospace">
"{{ admin_config_model.solution.clean }}"
</span>
</dd>
<dt>Transformation</dt>
<dd>
Whitespace:
<span class="is-uppercase is-family-monospace">
{{ admin_config_model.solution.whitespace }}
</span>
</dd>
<dd>
Sonderzeichen:
<span class="is-uppercase is-family-monospace">
{{ admin_config_model.solution.special_chars }}
</span>
</dd>
<dd>
Buchstaben:
<span class="is-uppercase is-family-monospace">
{{ admin_config_model.solution.case }}
</span>
</dd>
</dl>
<h3>Rätsel</h3>
<dl>
<dt>Offene Türchen</dt>
<dd>{{ store.user_doors.length }}</dd>
<dt>Zeit zum nächsten Türchen</dt>
<dd v-if="store.next_door_target === null">
Kein nächstes Türchen
</dd>
<dd v-else><CountDown :until="store.next_door_target" /></dd>
<dt>Erstes Türchen</dt>
<dd>{{ fmt_puzzle_date("first") }}</dd>
<dt>Nächstes Türchen</dt>
<dd>{{ fmt_puzzle_date("next") }}</dd>
<dt>Letztes Türchen</dt>
<dd>{{ fmt_puzzle_date("last") }}</dd>
<dt>Rätsel schließt nach</dt>
<dd>{{ fmt_puzzle_date("end") }}</dd>
<dt>Zufalls-Seed</dt>
<dd class="is-family-monospace">
"{{ admin_config_model.puzzle.seed }}"
</dd>
<dt>Extra-Tage</dt>
<dd>
<template
v-for="(day, index) in admin_config_model.puzzle.extra_days"
:key="`extra_day-${index}`"
>
<span>
<template v-if="index > 0">, </template>
{{ day }}
</span>
</template>
</dd>
<dt>Leere Türchen</dt>
<dd v-if="admin_config_model.puzzle.skip_empty">Überspringen</dd>
<dd v-else>Anzeigen</dd>
</dl>
</div>
</div>
<div class="column is-one-third">
<div class="content">
<h3>Kalender</h3>
<dl>
<dt>Definition</dt>
<dd>{{ admin_config_model.calendar.config_file }}</dd>
<dt>Hintergrundbild</dt>
<dd>{{ admin_config_model.calendar.background }}</dd>
<dt>Favicon</dt>
<dd>{{ admin_config_model.calendar.favicon }}</dd>
<dt>Türchen ({{ doors.length }} Stück)</dt>
<dd>
<template v-for="(door, index) in doors" :key="`door-${index}`">
<span>
<template v-if="index > 0">, </template>
{{ door.day }}
</span>
</template>
</dd>
</dl>
<h3>Bilder</h3>
<dl>
<dt>Größe</dt>
<dd>{{ admin_config_model.image.size }} px</dd>
<dt>Rand</dt>
<dd>{{ admin_config_model.image.border }} px</dd>
<dt>Schriftarten</dt>
<dd
v-for="(font, index) in admin_config_model.fonts"
:key="`font-${index}`"
>
{{ font.file }} ({{ font.size }} pt)
</dd>
</dl>
</div>
</div>
<div class="column is-one-third">
<div class="content">
<h3>WebDAV</h3>
<dl>
<dt>URL</dt>
<dd>{{ admin_config_model.webdav.url }}</dd>
<dt>Zugangsdaten</dt>
<dd class="is-family-monospace">
<BulmaSecret
@show="load_credentials(creds.dav, 'admin/credentials/dav')"
@hide="clear_credentials(creds.dav)"
>
<span class="tag is-danger">user</span>
{{ creds.dav.username }}
<br />
<span class="tag is-danger">pass</span>
{{ creds.dav.password }}
</BulmaSecret>
</dd>
<dt>Cache-Dauer</dt>
<dd>{{ admin_config_model.webdav.cache_ttl }} s</dd>
<dt>Konfigurationsdatei</dt>
<dd>{{ admin_config_model.webdav.config_file }}</dd>
</dl>
</div>
<div class="content">
<h3>Sonstige</h3>
<dl>
<dt>Redis</dt>
<dd>Host: {{ admin_config_model.redis.host }}</dd>
<dd>Port: {{ admin_config_model.redis.port }}</dd>
<dd>Datenbank: {{ admin_config_model.redis.db }}</dd>
<dd>Protokoll: {{ admin_config_model.redis.protocol }}</dd>
<dt>UI-Admin</dt>
<dd class="is-family-monospace">
<BulmaSecret
@show="load_credentials(creds.ui, 'admin/credentials/ui')"
@hide="clear_credentials(creds.ui)"
>
<span class="tag is-danger">user</span>
{{ creds.ui.username }}
<br />
<span class="tag is-danger">pass</span>
{{ creds.ui.password }}
</BulmaSecret>
</dd>
</dl>
</div>
</div>
</div>
</div>
</BulmaDrawer>
</template>
<script setup lang="ts">
import { API } from "@/lib/api";
import type { AdminConfigModel, Credentials, DoorSaved } from "@/lib/model";
import { advent22Store } from "@/lib/store";
import { DateTime } from "luxon";
import { ref } from "vue";
import BulmaDrawer from "../bulma/Drawer.vue";
import BulmaSecret from "../bulma/Secret.vue";
import CountDown from "../CountDown.vue";
const store = advent22Store();
const admin_config_model = ref<AdminConfigModel>({
solution: {
value: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
whitespace: "KEEP",
special_chars: "KEEP",
case: "KEEP",
clean: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
},
puzzle: {
first: "2023-12-01",
next: "2023-12-01",
last: "2023-12-24",
end: "2024-04-01",
seed: "",
extra_days: [],
skip_empty: true,
},
calendar: {
config_file: "lorem ipsum",
background: "dolor sit",
favicon: "sit amet",
},
image: {
size: 500,
border: 0,
},
fonts: [{ file: "consetetur", size: 0 }],
redis: {
host: "0.0.0.0",
port: 6379,
db: 0,
protocol: 3,
},
webdav: {
url: "sadipscing elitr",
cache_ttl: 0,
config_file: "sed diam nonumy",
},
});
const doors = ref<DoorSaved[]>([]);
const creds = ref<Record<string, Credentials>>({
dav: {
username: "",
password: "",
},
ui: {
username: "",
password: "",
},
});
function fmt_puzzle_date(name: keyof AdminConfigModel["puzzle"]): string {
const iso_date = admin_config_model.value.puzzle[name];
if (!(typeof iso_date === "string")) return "-";
return DateTime.fromISO(iso_date).toLocaleString(DateTime.DATE_SHORT);
}
async function on_open(): Promise<void> {
const [store_update, new_admin_config_model, new_doors] = await Promise.all([
store.update(),
API.request<AdminConfigModel>("admin/config_model"),
API.request<DoorSaved[]>("admin/doors"),
]);
void store_update; // discard value
admin_config_model.value = new_admin_config_model;
doors.value = new_doors;
clear_credentials(creds.value.dav);
clear_credentials(creds.value.ui);
}
async function load_credentials(
creds: Credentials,
endpoint: string,
): Promise<void> {
try {
const new_creds = await API.request<Credentials>(endpoint);
creds.username = new_creds.username;
creds.password = new_creds.password;
} catch {}
}
function clear_credentials(creds: Credentials): void {
creds.username = "";
creds.password = "";
}
</script>
<style scoped>
dd {
overflow-x: auto;
}
</style>

View file

@ -0,0 +1,174 @@
<template>
<BulmaDrawer header="Türchen bearbeiten" :opening="load_doors">
<nav class="level is-mobile mb-0" style="overflow-x: auto">
<BulmaButton
:disabled="current_step === 0"
class="level-item is-link"
@click="current_step--"
:icon="['fas', 'fa-backward']"
/>
<BulmaBreadcrumbs
:steps="steps"
v-model="current_step"
class="level-item mb-0"
/>
<BulmaButton
:disabled="current_step === 2"
class="level-item is-link"
@click="current_step++"
:icon="['fas', 'fa-forward']"
/>
</nav>
<div class="card-content pb-0">
<div v-if="doors.length > 0" class="content">
<p>Für diese Tage ist ein Türchen vorhanden:</p>
<div class="tags">
<span
v-for="(door, index) in doors.toSorted((a, b) => a.day - b.day)"
:key="`door-${index}`"
class="tag is-primary"
>
{{ door.day }}
</span>
</div>
</div>
</div>
<DoorPlacer v-if="current_step === 0" v-model="doors" />
<DoorChooser v-if="current_step === 1" v-model="doors" />
<div v-if="current_step === 2" class="card-content">
<Calendar :doors="doors" />
</div>
<footer class="card-footer is-flex is-justify-content-space-around">
<BulmaButton
class="card-footer-item is-danger"
@click="on_download"
:icon="['fas', 'fa-cloud-arrow-down']"
:busy="loading_doors"
text="Laden"
/>
<BulmaButton
class="card-footer-item is-warning"
@click="on_discard"
:icon="['fas', 'fa-trash']"
text="Löschen"
/>
<BulmaButton
class="card-footer-item is-success"
@click="on_upload"
:icon="['fas', 'fa-cloud-arrow-up']"
:busy="saving_doors"
text="Speichern"
/>
</footer>
</BulmaDrawer>
</template>
<script setup lang="ts">
import { API } from "@/lib/api";
import { APIError } from "@/lib/api_error";
import type { DoorSaved } from "@/lib/model";
import { Door } from "@/lib/rects/door";
import { toast } from "bulma-toast";
import { ref } from "vue";
import type { BCStep } from "../bulma/Breadcrumbs.vue";
import Calendar from "../Calendar.vue";
import BulmaBreadcrumbs from "../bulma/Breadcrumbs.vue";
import BulmaButton from "../bulma/Button.vue";
import BulmaDrawer from "../bulma/Drawer.vue";
import DoorChooser from "../editor/DoorChooser.vue";
import DoorPlacer from "../editor/DoorPlacer.vue";
const steps: BCStep[] = [
{ label: "Platzieren", icon: ["fas", "fa-crosshairs"] },
{ label: "Ordnen", icon: ["fas", "fa-list-ol"] },
{ label: "Vorschau", icon: ["fas", "fa-magnifying-glass"] },
];
const doors = ref<Door[]>([]);
const current_step = ref(0);
const loading_doors = ref(false);
const saving_doors = ref(false);
async function load_doors(): Promise<void> {
try {
const data = await API.request<DoorSaved[]>("admin/doors");
doors.value.length = 0;
for (const value of data) {
doors.value.push(Door.load(value));
}
} catch (error) {
APIError.alert(error);
throw null;
}
}
async function save_doors(): Promise<void> {
try {
const data: DoorSaved[] = [];
for (const door of doors.value) {
data.push(door.save());
}
await API.request<void>({
endpoint: "admin/doors",
method: "PUT",
data: data,
});
} catch (error) {
APIError.alert(error);
throw null;
}
}
async function on_download(): Promise<void> {
if (confirm("Aktuelle Änderungen verwerfen und Status vom Server laden?")) {
loading_doors.value = true;
try {
load_doors();
toast({
message: "Erfolgreich!",
type: "is-success",
duration: 2e3,
});
} finally {
loading_doors.value = false;
}
}
}
function on_discard(): void {
if (confirm("Alle Türchen löschen? (nur lokal)")) {
// empty `doors` array
doors.value.length = 0;
}
}
async function on_upload(): Promise<void> {
if (confirm("Aktuelle Änderungen an den Server schicken?")) {
saving_doors.value = true;
try {
save_doors();
load_doors();
toast({
message: "Erfolgreich!",
type: "is-success",
duration: 2e3,
});
} finally {
saving_doors.value = false;
}
}
}
</script>

View file

@ -0,0 +1,33 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<nav class="breadcrumb has-succeeds-separator">
<ul>
<li
v-for="(step, index) in steps"
:key="index"
:class="model === index ? 'is-active' : ''"
@click.left="model = index"
>
<a :class="model === index ? 'has-text-primary' : ''">
<span class="icon is-small">
<FontAwesomeIcon :icon="step.icon" />
</span>
<span>{{ step.label }}</span>
</a>
</li>
</ul>
</nav>
</template>
<script setup lang="ts">
export interface BCStep {
label: string;
icon: string | string[];
}
const model = defineModel<number>({ required: true });
defineProps<{
steps: BCStep[];
}>();
</script>

View file

@ -0,0 +1,28 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<button class="button">
<slot name="default">
<span v-if="icon !== undefined" class="icon">
<FontAwesomeIcon
v-if="icon !== undefined"
:icon="icon"
:beat-fade="busy"
/>
</span>
</slot>
<span v-if="text !== undefined">{{ text }}</span>
</button>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
icon?: string | string[];
text?: string;
busy?: boolean;
}>(),
{
busy: false,
},
);
</script>

View file

@ -0,0 +1,93 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<div class="card">
<header class="card-header is-unselectable" style="cursor: pointer">
<p class="card-header-title" @click="toggle">{{ header }}</p>
<p v-if="refreshable && is_open" class="card-header-icon px-0">
<BulmaButton class="is-small is-primary" @click="load">
<FontAwesomeIcon
:icon="['fas', 'arrows-rotate']"
:spin="state === 'loading'"
/>
</BulmaButton>
</p>
<button class="card-header-icon" @click="toggle">
<span class="icon">
<FontAwesomeIcon
:icon="['fas', is_open ? 'angle-down' : 'angle-right']"
/>
</span>
</button>
</header>
<slot v-if="state === 'loading'" name="loading">
<div class="card-content">
<progress class="progress is-primary" />
</div>
</slot>
<slot v-else-if="state === 'err'" name="error">
<div class="card-content has-text-danger has-text-centered">
<span class="icon is-large">
<FontAwesomeIcon :icon="['fas', 'ban']" size="3x" />
</span>
</div>
</slot>
<slot v-else-if="state === 'ok'" name="default" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import BulmaButton from "./Button.vue";
const props = withDefaults(
defineProps<{
header: string;
opening?: () => Promise<void>;
refreshable?: boolean;
}>(),
{ opening: async () => {}, refreshable: false },
);
const state = ref<"closed" | "loading" | "ok" | "err">("closed");
const is_open = computed(() => state.value !== "closed");
async function toggle(): Promise<void> {
switch (state.value) {
case "closed":
// start opening when closed
await load();
break;
case "loading":
// don't toggle when loading
break;
default:
state.value = "closed";
break;
}
}
async function load(): Promise<void> {
state.value = "loading";
try {
await props.opening();
state.value = "ok";
} catch {
state.value = "err";
}
}
</script>
<style scoped>
div.card:not(:last-child) {
margin-bottom: 1.5rem;
}
</style>

View file

@ -0,0 +1,52 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<slot v-if="state === 'visible'" name="default" />
<span v-else>***</span>
<BulmaButton
:class="`is-small is-${record.color} ml-2`"
:icon="['fas', record.icon]"
:busy="state === 'pending'"
@click="on_click"
/>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import BulmaButton from "./Button.vue";
const emit = defineEmits<{
(event: "show"): void;
(event: "hide"): void;
}>();
type State = "hidden" | "pending" | "visible";
const state = ref<State>("hidden");
const state_map: Record<State, { color: string; icon: string; next: State }> = {
hidden: { color: "primary", icon: "eye-slash", next: "pending" },
pending: { color: "warning", icon: "eye-slash", next: "visible" },
visible: { color: "danger", icon: "eye", next: "hidden" },
} as const;
const record = computed(() => state_map[state.value] ?? state_map.hidden);
let pending_timeout: number | undefined;
function on_click(): void {
state.value = record.value.next;
if (state.value === "hidden") {
emit("hide");
}
if (state.value === "pending") {
pending_timeout = window.setTimeout(() => (state.value = "hidden"), 2500);
} else {
window.clearTimeout(pending_timeout);
}
if (state.value === "visible") {
emit("show");
}
}
</script>

View file

@ -0,0 +1,47 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<div style="display: none">
<div v-bind="$attrs" ref="message">
<slot name="default" />
</div>
</div>
</template>
<script setup lang="ts">
import { type Options as ToastOptions, toast } from "bulma-toast";
import { onMounted, useTemplateRef } from "vue";
export type HBulmaToast = {
show(options: ToastOptions): void;
hide(): void;
};
const emit = defineEmits<{
(event: "handle", handle: HBulmaToast): void;
}>();
const message_div = useTemplateRef("message");
onMounted(() =>
emit("handle", {
show(options: ToastOptions = {}): void {
if (message_div.value === null) return;
toast({
...options,
single: true,
message: message_div.value,
});
},
hide(): void {
// using "toast" detaches "message" from the invisible "div"
// => toast_div is not part of this component!
const toast_div = message_div.value?.parentElement;
const delete_button = toast_div?.querySelector("button.delete");
if (!(delete_button instanceof HTMLButtonElement)) return;
delete_button.click();
},
}),
);
</script>

View file

@ -0,0 +1,34 @@
<template>
<SVGRect
variant="primary"
:visible="store.is_touch_device || force_visible"
:rectangle="door.position"
>
<div
class="has-text-danger"
style="text-shadow: 0 0 10px white, 0 0 20px white"
>
{{ door.day }}
</div>
</SVGRect>
</template>
<script setup lang="ts">
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store";
import type { VueLike } from "@/lib/helpers";
import SVGRect from "./SVGRect.vue";
const store = advent22Store();
withDefaults(
defineProps<{
door: VueLike<Door>;
force_visible?: boolean;
}>(),
{
force_visible: false,
},
);
</script>

View file

@ -0,0 +1,79 @@
<template>
<foreignObject
:x="Math.round(aspect_ratio * rectangle.left)"
:y="rectangle.top"
:width="Math.round(aspect_ratio * rectangle.width)"
:height="rectangle.height"
:style="`transform: scaleX(${1 / aspect_ratio})`"
>
<div
xmlns="http://www.w3.org/1999/xhtml"
:class="`px-2 is-flex is-align-items-center is-justify-content-center is-size-2 has-text-weight-bold ${variant} ${
visible ? 'visible' : ''
}`"
style="height: inherit"
v-bind="$attrs"
>
<slot name="default" />
</div>
</foreignObject>
</template>
<script setup lang="ts">
import { type VueLike, unwrap_loading } from "@/lib/helpers";
import { Rectangle } from "@/lib/rects/rectangle";
import { advent22Store } from "@/lib/store";
import { computed } from "vue";
const store = advent22Store();
type BulmaVariant =
| "primary"
| "link"
| "info"
| "success"
| "warning"
| "danger";
withDefaults(
defineProps<{
variant: BulmaVariant;
visible?: boolean;
rectangle: VueLike<Rectangle>;
}>(),
{
visible: true,
},
);
const aspect_ratio = computed(() => {
try {
return unwrap_loading(store.background_image).aspect_ratio;
} catch {
return 1;
}
});
</script>
<style lang="scss" scoped>
@use "@/bulma-scheme" as scheme;
foreignObject > div {
&:not(.visible, :hover):deep() > * {
display: none;
}
&.visible,
&:hover {
border-width: 2px;
border-style: solid;
@each $name, $color in scheme.$colors {
&.#{$name} {
background-color: rgba($color, 0.3);
border-color: rgba($color, 0.9);
}
}
}
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
version="1.1"
viewBox="0 0 1000 1000"
preserveAspectRatio="none"
@contextmenu.prevent
@mousedown="transform_mouse_event"
@mousemove="transform_mouse_event"
@mouseup="transform_mouse_event"
@click="transform_mouse_event"
@dblclick="transform_mouse_event"
>
<slot name="default" />
</svg>
</template>
<script setup lang="ts">
import { Vector2D } from "@/lib/rects/vector2d";
function get_event_thous(event: MouseEvent): Vector2D {
if (!(event.currentTarget instanceof SVGSVGElement)) {
return new Vector2D();
}
return new Vector2D(
Math.round((event.offsetX / event.currentTarget.clientWidth) * 1000),
Math.round((event.offsetY / event.currentTarget.clientHeight) * 1000),
);
}
type TCEventType = "mousedown" | "mousemove" | "mouseup" | "click" | "dblclick";
const is_tceventtype = (t: unknown): t is TCEventType =>
t === "mousedown" ||
t === "mousemove" ||
t === "mouseup" ||
t === "click" ||
t === "dblclick";
const emit = defineEmits<{
(event: TCEventType, e: MouseEvent, point: Vector2D): void;
}>();
function transform_mouse_event(event: MouseEvent): void {
if (!is_tceventtype(event.type)) return;
emit(event.type, event, get_event_thous(event));
}
</script>
<style scoped>
svg {
height: 100%;
width: 100%;
position: absolute;
left: 0;
top: 0;
z-index: auto;
}
</style>

View file

@ -0,0 +1,119 @@
<template>
<ThouCanvas
@mousedown.left="draw_start"
@mouseup.left="draw_finish"
@mousedown.right="drag_start"
@mouseup.right="drag_finish"
@mousemove="on_mousemove"
@click.middle="remove_rect"
@dblclick.left="remove_rect"
>
<CalendarDoor
v-for="(door, index) in model"
:key="`door-${index}`"
:door="door"
force_visible
/>
<SVGRect
v-if="preview_visible"
variant="success"
:rectangle="preview"
visible
/>
</ThouCanvas>
</template>
<script setup lang="ts">
import { Door } from "@/lib/rects/door";
import { Rectangle } from "@/lib/rects/rectangle";
import { Vector2D } from "@/lib/rects/vector2d";
import { computed, ref } from "vue";
import type { VueLike } from "@/lib/helpers";
import CalendarDoor from "../calendar/CalendarDoor.vue";
import SVGRect from "../calendar/SVGRect.vue";
import ThouCanvas from "../calendar/ThouCanvas.vue";
type CanvasState =
| { kind: "idle" }
| { kind: "drawing" }
| { kind: "dragging"; door: VueLike<Door>; origin: Vector2D };
const model = defineModel<VueLike<Door>[]>({ required: true });
const MIN_RECT_AREA = 300;
const state = ref<CanvasState>({ kind: "idle" });
const preview = ref(new Rectangle());
const preview_visible = computed(() => state.value.kind !== "idle");
function pop_door(point: Vector2D): VueLike<Door> | undefined {
const idx = model.value.findIndex((rect) => rect.position.contains(point));
if (idx === -1) return;
return model.value.splice(idx, 1)[0];
}
function draw_start(event: MouseEvent, point: Vector2D): void {
if (preview_visible.value) return;
preview.value = new Rectangle(point, point);
state.value = { kind: "drawing" };
}
function draw_finish(): void {
if (state.value.kind !== "drawing") return;
if (preview.value.area >= MIN_RECT_AREA) {
model.value.push(new Door(preview.value));
}
state.value = { kind: "idle" };
}
function drag_start(event: MouseEvent, point: Vector2D): void {
if (preview_visible.value) return;
const drag_door = pop_door(point);
if (drag_door === undefined) return;
preview.value = drag_door.position;
state.value = { kind: "dragging", door: drag_door, origin: point };
}
function drag_finish(): void {
if (state.value.kind !== "dragging") return;
model.value.push(new Door(preview.value, state.value.door.day));
state.value = { kind: "idle" };
}
function on_mousemove(event: MouseEvent, point: Vector2D): void {
if (state.value.kind === "drawing") {
preview.value = preview.value.update(undefined, point);
} else if (state.value.kind === "dragging") {
const movement = point.minus(state.value.origin);
preview.value = state.value.door.position.move(movement);
}
}
function remove_rect(event: MouseEvent, point: Vector2D): void {
if (preview_visible.value) return;
pop_door(point);
}
</script>
<style lang="scss" scoped>
svg {
cursor: crosshair;
* {
pointer-events: none;
}
}
</style>

View file

@ -0,0 +1,36 @@
<template>
<div class="card-content">
<div class="content is-small">
<h3>Steuerung</h3>
<ul>
<li>Linksklick: Türchen bearbeiten</li>
<li>Tastatur: Tag eingeben</li>
<li>[Enter]: Tag speichern</li>
<li>[Esc]: Eingabe Abbrechen</li>
<li>[Entf]: Tag entfernen</li>
</ul>
</div>
<figure class="image is-unselectable">
<img :src="unwrap_loading(store.background_image).data_url" />
<ThouCanvas>
<PreviewDoor
v-for="(_, index) in model"
:key="`door-${index}`"
v-model="model[index]"
/>
</ThouCanvas>
</figure>
</div>
</template>
<script setup lang="ts">
import { type VueLike, unwrap_loading } from "@/lib/helpers";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store";
import ThouCanvas from "../calendar/ThouCanvas.vue";
import PreviewDoor from "./PreviewDoor.vue";
const model = defineModel<VueLike<Door>[]>({ required: true });
const store = advent22Store();
</script>

View file

@ -0,0 +1,27 @@
<template>
<div class="card-content">
<div class="content is-small">
<h3>Steuerung</h3>
<ul>
<li>Linksklick + Ziehen: Neues Türchen erstellen</li>
<li>Rechtsklick + Ziehen: Türchen verschieben</li>
<li>Doppel- oder Mittelklick: Türchen löschen</li>
</ul>
</div>
<figure class="image is-unselectable">
<img :src="unwrap_loading(store.background_image).data_url" />
<DoorCanvas v-model="model" />
</figure>
</div>
</template>
<script setup lang="ts">
import { type VueLike, unwrap_loading } from "@/lib/helpers";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store";
import DoorCanvas from "./DoorCanvas.vue";
const model = defineModel<VueLike<Door>[]>({ required: true });
const store = advent22Store();
</script>

View file

@ -0,0 +1,71 @@
<template>
<SVGRect
style="cursor: text"
:rectangle="model.position"
:variant="editing ? 'success' : 'primary'"
@click.left.stop="on_click"
visible
>
<input
v-if="editing"
v-model="day_str"
ref="day_input"
class="input is-large"
type="number"
:min="Door.MIN_DAY"
placeholder="Tag"
@keydown="on_keydown"
/>
<div v-else class="has-text-danger">
{{ model.day > 0 ? model.day : "*" }}
</div>
</SVGRect>
</template>
<script setup lang="ts">
import { Door } from "@/lib/rects/door";
import { ref, useTemplateRef } from "vue";
import { type VueLike, unwrap_vuelike, wait_for } from "@/lib/helpers";
import SVGRect from "../calendar/SVGRect.vue";
const model = defineModel<VueLike<Door>>({ required: true });
const day_input = useTemplateRef("day_input");
const day_str = ref("");
const editing = ref(false);
function toggle_editing(): void {
day_str.value = String(model.value.day);
editing.value = !editing.value;
}
function on_click(event: MouseEvent): void {
if (!(event.target instanceof HTMLDivElement)) return;
if (editing.value) {
unwrap_vuelike(model.value).day = day_str.value;
} else {
wait_for(
() => day_input.value !== null,
() => day_input.value!.select(),
);
}
toggle_editing();
}
function on_keydown(event: KeyboardEvent): void {
if (!editing.value) return;
if (event.key === "Enter") {
unwrap_vuelike(model.value).day = day_str.value;
toggle_editing();
} else if (event.key === "Delete") {
model.value.day = -1;
toggle_editing();
} else if (event.key === "Escape") {
toggle_editing();
}
}
</script>

6
ui/src/d.ts/shims-vue.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/* eslint-disable */
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

92
ui/src/lib/api.ts Normal file
View file

@ -0,0 +1,92 @@
import type {
AxiosBasicCredentials,
AxiosRequestConfig,
Method,
RawAxiosRequestHeaders,
} from "axios";
import axios from "axios";
import { APIError } from "./api_error";
interface Params {
endpoint: string;
method?: Method;
data?: unknown;
headers?: RawAxiosRequestHeaders;
config?: AxiosRequestConfig;
}
export class API {
private static get api_baseurl(): string {
// in production mode, return "proto://hostname/api"
if (process.env.NODE_ENV === "production") {
return `${window.location.protocol}//${window.location.host}/api`;
} else if (process.env.NODE_ENV !== "development") {
// not in prouction or development mode
// eslint-disable-next-line no-console
console.warn("Unexpected NODE_ENV value: ", process.env.NODE_ENV);
}
// in development mode, return "proto://hostname:8000/api"
return `${window.location.protocol}//${window.location.hostname}:8000/api`;
}
private static readonly axios = axios.create({
timeout: 10e3,
baseURL: this.api_baseurl,
});
private static readonly creds_key = "advent22/credentials";
public static set creds(value: AxiosBasicCredentials | null) {
if (value === null) {
localStorage.removeItem(this.creds_key);
} else {
localStorage.setItem(this.creds_key, JSON.stringify(value));
}
}
public static get creds(): AxiosBasicCredentials {
const stored_auth = JSON.parse(localStorage.getItem(this.creds_key) ?? "null");
if (
stored_auth !== null &&
Object.hasOwn(stored_auth, "username") &&
Object.hasOwn(stored_auth, "password")
) {
return stored_auth;
}
return { username: "", password: "" };
}
private static get_axios_config({
endpoint,
method = "GET",
data,
headers = {},
config = {},
}: Params): AxiosRequestConfig {
return {
url: endpoint,
method: method,
data: data,
auth: this.creds,
headers: headers,
...config,
};
}
public static async request<T = string>(p: Params): Promise<T>;
public static async request<T = string>(p: string): Promise<T>;
public static async request<T = string>(p: Params | string): Promise<T> {
if (typeof p === "string") p = { endpoint: p };
try {
const response = await this.axios.request<T>(this.get_axios_config(p));
return response.data;
} catch (reason) {
// eslint-disable-next-line no-console
console.error(`Failed to query ${p.endpoint}: ${reason}`);
throw new APIError(reason, p.endpoint);
}
}
}

75
ui/src/lib/api_error.ts Normal file
View file

@ -0,0 +1,75 @@
import { AxiosError } from "axios";
import { toast } from "bulma-toast";
export class APIError extends Error {
axios_error?: AxiosError;
constructor(reason: unknown, endpoint: string) {
super(endpoint); // sets this.message to the endpoint
Object.setPrototypeOf(this, APIError.prototype);
if (reason instanceof AxiosError) {
this.axios_error = reason;
}
}
public format(): string {
let msg =
"Unbekannter Fehler, bitte wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
let code = "U";
const result = () => `${msg} (Fehlercode: ${code}/${this.message})`;
if (this.axios_error === undefined) return result();
switch (this.axios_error.code) {
case "ECONNABORTED":
// API unerreichbar
msg =
"API antwortet nicht, bitte später wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
code = "D";
break;
case "ERR_NETWORK":
// Netzwerk nicht verbunden
msg = "Sieht aus, als sei deine Netzwerkverbindung gestört.";
code = "N";
break;
default:
if (this.axios_error.response === undefined) return result();
switch (this.axios_error.response.status) {
case 401:
// UNAUTHORIZED
msg = "Netter Versuch :)";
code = "A";
break;
case 422:
// UNPROCESSABLE ENTITY
msg = "Funktion ist kaputt, bitte Admin benachrichtigen!";
code = "I";
break;
default:
// HTTP
code = `H${this.axios_error.response.status}`;
break;
}
break;
}
return result();
}
public alert(): void {
toast({
message: this.format(),
type: "is-danger",
});
}
public static alert(error: unknown): void {
new APIError(error, "").alert();
}
}

14
ui/src/lib/fontawesome.ts Normal file
View file

@ -0,0 +1,14 @@
/* import font awesome icon component */
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
/* import the fontawesome core */
import { library } from "@fortawesome/fontawesome-svg-core";
/* import specific icons */
// import { fab } from "@fortawesome/free-brands-svg-icons";
import { fas } from "@fortawesome/free-solid-svg-icons";
/* add icons to the library */
library.add(fas);
export default FontAwesomeIcon;

52
ui/src/lib/helpers.ts Normal file
View file

@ -0,0 +1,52 @@
import { nextTick, type UnwrapRef } from "vue";
import { APIError } from "./api_error";
export function objForEach<T>(
obj: T,
f: (k: keyof T, v: T[keyof T]) => void,
): void {
for (const k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k)) {
f(k, obj[k]);
}
}
}
export type VueLike<T> = T | UnwrapRef<T>;
export function unwrap_vuelike<T>(value: VueLike<T>): T {
return value as T;
}
export type Loading<T> = T | "loading" | "error";
export function unwrap_loading<T>(o: Loading<T>): T {
if (o === "loading" || o === "error") throw null;
return o;
}
export function wait_for(condition: () => boolean, action: () => void): void {
const enqueue_action = () => {
if (!condition()) {
nextTick(enqueue_action);
return;
}
action();
};
enqueue_action();
}
export function handle_error(error: unknown): void {
if (error instanceof APIError) {
error.alert();
} else {
// eslint-disable-next-line no-console
console.error(error);
}
}
export function name_door(day: number): string {
return `Türchen ${day}`;
}

70
ui/src/lib/model.ts Normal file
View file

@ -0,0 +1,70 @@
export interface AdminConfigModel {
solution: {
value: string;
whitespace: string;
special_chars: string;
case: string;
clean: string;
};
puzzle: {
first: string;
next: string | null;
last: string;
end: string;
seed: string;
extra_days: number[];
skip_empty: boolean;
};
calendar: {
config_file: string;
background: string;
favicon: string;
};
image: {
size: number;
border: number;
};
fonts: { file: string; size: number }[];
redis: {
host: string;
port: number;
db: number;
protocol: number;
};
webdav: {
url: string;
cache_ttl: number;
config_file: string;
};
}
export interface SiteConfigModel {
title: string;
subtitle: string;
content: string;
footer: string;
}
export interface NumStrDict {
[key: number]: string;
}
export interface DoorSaved {
day: number;
x1: number;
y1: number;
x2: number;
y2: number;
}
export interface ImageData {
width: number;
height: number;
aspect_ratio: number;
data_url: string;
}
export interface Credentials {
username: string;
password: string;
}

54
ui/src/lib/rects/door.ts Normal file
View file

@ -0,0 +1,54 @@
import { type VueLike, unwrap_vuelike } from "../helpers";
import type { DoorSaved } from "../model";
import { Rectangle } from "./rectangle";
import { Vector2D } from "./vector2d";
export class Door {
public static readonly MIN_DAY = 1;
private _day = Door.MIN_DAY;
public position: Rectangle;
constructor(position: VueLike<Rectangle>);
constructor(position: VueLike<Rectangle>, day: number);
constructor(position: VueLike<Rectangle>, day = Door.MIN_DAY) {
this.day = day;
this.position = unwrap_vuelike(position);
}
public get day(): number {
return this._day;
}
public set day(value: number | string) {
// integer coercion
let day = Number(value);
day =
!Number.isNaN(day) && Number.isFinite(day)
? Math.trunc(day)
: Door.MIN_DAY;
this._day = Math.max(day, Door.MIN_DAY);
}
public static load(serialized: DoorSaved): Door {
return new Door(
new Rectangle(
new Vector2D(serialized.x1, serialized.y1),
new Vector2D(serialized.x2, serialized.y2),
),
serialized.day,
);
}
public save(): DoorSaved {
return {
day: this.day,
x1: this.position.origin.x,
y1: this.position.origin.y,
x2: this.position.corner.x,
y2: this.position.corner.y,
};
}
}

View file

@ -0,0 +1,79 @@
import { Vector2D } from "./vector2d";
export class Rectangle {
private readonly corner_1: Vector2D;
private readonly corner_2: Vector2D;
constructor();
constructor(corner_1: Vector2D, corner_2: Vector2D);
constructor(corner_1 = new Vector2D(), corner_2 = new Vector2D()) {
this.corner_1 = corner_1;
this.corner_2 = corner_2;
}
public get origin(): Vector2D {
return new Vector2D(
Math.min(this.corner_1.x, this.corner_2.x),
Math.min(this.corner_1.y, this.corner_2.y),
);
}
public get left(): number {
return this.origin.x;
}
public get top(): number {
return this.origin.y;
}
public get corner(): Vector2D {
return new Vector2D(
Math.max(this.corner_1.x, this.corner_2.x),
Math.max(this.corner_1.y, this.corner_2.y),
);
}
public get size(): Vector2D {
return this.corner.minus(this.origin);
}
public get width(): number {
return this.size.x;
}
public get height(): number {
return this.size.y;
}
public get middle(): Vector2D {
return this.origin.plus(this.size.scale(0.5));
}
public get area(): number {
return this.width * this.height;
}
public equals(other: Rectangle): boolean {
return this.origin.equals(other.origin) && this.corner.equals(other.corner);
}
public contains(point: Vector2D): boolean {
return (
point.x >= this.origin.x &&
point.y >= this.origin.y &&
point.x <= this.corner.x &&
point.y <= this.corner.y
);
}
public update(corner_1?: Vector2D, corner_2?: Vector2D): Rectangle {
return new Rectangle(corner_1 ?? this.corner_1, corner_2 ?? this.corner_2);
}
public move(vector: Vector2D): Rectangle {
return new Rectangle(
this.corner_1.plus(vector),
this.corner_2.plus(vector),
);
}
}

View file

@ -0,0 +1,27 @@
export class Vector2D {
public readonly x: number;
public readonly y: number;
constructor();
constructor(x: number, y: number);
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
public plus(other: Vector2D): Vector2D {
return new Vector2D(this.x + other.x, this.y + other.y);
}
public minus(other: Vector2D): Vector2D {
return new Vector2D(this.x - other.x, this.y - other.y);
}
public scale(other: number): Vector2D {
return new Vector2D(this.x * other, this.y * other);
}
public equals(other: Vector2D): boolean {
return this.x === other.x && this.y === other.y;
}
}

131
ui/src/lib/store.ts Normal file
View file

@ -0,0 +1,131 @@
import { acceptHMRUpdate, defineStore } from "pinia";
import { API } from "./api";
import type { Loading } from "./helpers";
import type { Credentials, DoorSaved, ImageData, SiteConfigModel } from "./model";
import { Door } from "./rects/door";
declare global {
interface Navigator {
readonly msMaxTouchPoints: number;
}
}
type State = {
on_initialized: (() => void)[] | null;
is_touch_device: boolean;
is_admin: boolean;
site_config: SiteConfigModel;
background_image: Loading<ImageData>;
user_doors: Door[];
next_door_target: number | null;
};
export const advent22Store = defineStore("advent22", {
state: (): State => ({
on_initialized: [],
is_touch_device:
window.matchMedia("(any-hover: none)").matches ||
"ontouchstart" in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0,
is_admin: false,
site_config: {
title: document.title,
subtitle: "",
content: "",
footer: "",
},
background_image: "loading",
user_doors: [],
next_door_target: null,
}),
actions: {
async init(): Promise<void> {
await this.update();
if (this.on_initialized !== null) {
for (const callback of this.on_initialized) callback();
}
this.on_initialized = null;
},
async update(): Promise<void> {
try {
const favicon = await API.request<ImageData>("user/favicon");
const link: HTMLLinkElement =
document.querySelector("link[rel*='icon']") ??
document.createElement("link");
link.rel = "shortcut icon";
link.type = "image/x-icon";
link.href = favicon.data_url;
if (link.parentElement === null)
document.getElementsByTagName("head")[0]!.appendChild(link);
} catch { }
try {
const [is_admin, site_config, background_image, user_doors, next_door] =
await Promise.all([
this.update_is_admin(),
API.request<SiteConfigModel>("user/site_config"),
API.request<ImageData>("user/background_image"),
API.request<DoorSaved[]>("user/doors"),
API.request<number | null>("user/next_door"),
]);
void is_admin; // discard value
document.title = site_config.title;
if (site_config.subtitle !== "")
document.title += " " + site_config.subtitle;
this.site_config = site_config;
this.background_image = background_image;
this.user_doors.length = 0;
for (const door_saved of user_doors) {
this.user_doors.push(Door.load(door_saved));
}
if (next_door !== null) this.next_door_target = Date.now() + next_door;
} catch {
this.background_image = "error";
}
},
when_initialized(callback: () => void): void {
if (this.on_initialized === null) {
callback();
} else {
this.on_initialized.push(callback);
}
},
async update_is_admin(): Promise<boolean> {
this.is_admin = await API.request<boolean>("admin/is_admin");
return this.is_admin;
},
async login(creds: Credentials): Promise<boolean> {
API.creds = creds;
return await this.update_is_admin();
},
logout() {
API.creds = null;
this.is_admin = false;
},
toggle_touch_device(): void {
this.is_touch_device = !this.is_touch_device;
},
},
});
if (import.meta.webpackHot) {
import.meta.webpackHot.accept(
acceptHMRUpdate(advent22Store, import.meta.webpackHot),
);
}

23
ui/src/main.scss Normal file
View file

@ -0,0 +1,23 @@
@charset "utf-8";
@use "sass:map";
//==============
// bulma
//==============
// custom color scheme
@use "bulma-scheme" as scheme;
@use "bulma/sass" with (
$primary: map.get(scheme.$colors, "primary"),
$link: map.get(scheme.$colors, "link"),
$info: map.get(scheme.$colors, "info"),
$success: map.get(scheme.$colors, "success"),
$warning: map.get(scheme.$colors, "warning"),
$danger: map.get(scheme.$colors, "danger")
);
//==============
// main imports
//==============
@forward "animate.css/animate";

27
ui/src/main.ts Normal file
View file

@ -0,0 +1,27 @@
import FontAwesomeIcon from "@/lib/fontawesome";
import { advent22Store } from "@/lib/store";
import { setDefaults as toast_set_defaults } from "bulma-toast";
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
import "@/main.scss";
const app = createApp(App);
app.use(createPinia());
app.component("FontAwesomeIcon", FontAwesomeIcon);
advent22Store().init();
app.mount("#app");
toast_set_defaults({
duration: 10e3,
pauseOnHover: true,
dismissible: true,
closeOnClick: false,
type: "is-white",
position: "top-center",
animate: { in: "backInDown", out: "backOutUp" },
});

View file

@ -0,0 +1,90 @@
import { expect } from "chai";
import { Rectangle } from "@/lib/rects/rectangle";
import { Vector2D } from "@/lib/rects/vector2d";
describe("Rectangle Tests", () => {
const v1 = new Vector2D(1, 2);
const v2 = new Vector2D(4, 6);
const r1 = new Rectangle(v1, v2);
const r2 = new Rectangle(v2, v1);
function check_rectangle(
r: Rectangle,
left: number,
top: number,
width: number,
height: number,
): void {
expect(r.left).to.equal(left);
expect(r.top).to.equal(top);
expect(r.width).to.equal(width);
expect(r.height).to.equal(height);
expect(r.area).to.equal(width * height);
expect(r.middle.x).to.equal(left + 0.5 * width);
expect(r.middle.y).to.equal(top + 0.5 * height);
}
it("should create a default rectangle", () => {
check_rectangle(new Rectangle(), 0, 0, 0, 0);
});
it("should create a rectangle", () => {
check_rectangle(r1, 1, 2, 3, 4);
});
it("should create the same rectangle backwards", () => {
check_rectangle(r2, 1, 2, 3, 4);
});
it("should compare rectangles", () => {
expect(r1.equals(r2)).to.be.true;
expect(r1.equals(new Rectangle())).to.be.false;
});
it("should create the same rectangle transposed", () => {
const v1t = new Vector2D(v1.x, v2.y);
const v2t = new Vector2D(v2.x, v1.y);
expect(r1.equals(new Rectangle(v1t, v2t))).to.be.true;
});
it("should contain itself", () => {
expect(r1.contains(v1)).to.be.true;
expect(r1.contains(v2)).to.be.true;
expect(r1.contains(r1.origin)).to.be.true;
expect(r1.contains(r1.corner)).to.be.true;
expect(r1.contains(r1.middle)).to.be.true;
});
it("should not contain certain points", () => {
expect(r1.contains(new Vector2D(0, 0))).to.be.false;
expect(r1.contains(new Vector2D(100, 100))).to.be.false;
});
it("should update a rectangle", () => {
const v = new Vector2D(1, 1);
check_rectangle(r1.update(v1.plus(v), undefined), 2, 3, 2, 3);
check_rectangle(r1.update(v1.minus(v), undefined), 0, 1, 4, 5);
check_rectangle(r1.update(undefined, v2.plus(v)), 1, 2, 4, 5);
check_rectangle(r1.update(undefined, v2.minus(v)), 1, 2, 2, 3);
check_rectangle(r1.update(v1.plus(v), v2.plus(v)), 2, 3, 3, 4);
check_rectangle(r1.update(v1.minus(v), v2.minus(v)), 0, 1, 3, 4);
check_rectangle(r1.update(v1.minus(v), v2.plus(v)), 0, 1, 5, 6);
check_rectangle(r1.update(v1.plus(v), v2.minus(v)), 2, 3, 1, 2);
});
it("should move a rectangle", () => {
const v = new Vector2D(1, 1);
check_rectangle(r1.move(v), 2, 3, 3, 4);
});
});

View file

@ -0,0 +1,41 @@
import { expect } from "chai";
import { Vector2D } from "@/lib/rects/vector2d";
describe("Vector2D Tests", () => {
const v = new Vector2D(1, 2);
it("should create a default vector", () => {
const v0 = new Vector2D();
expect(v0.x).to.equal(0);
expect(v0.y).to.equal(0);
});
it("should create a vector", () => {
expect(v.x).to.equal(1);
expect(v.y).to.equal(2);
});
it("should add vectors", () => {
const v2 = v.plus(new Vector2D(3, 4));
expect(v2.x).to.equal(4);
expect(v2.y).to.equal(6);
});
it("should subtract vectors", () => {
const v2 = v.minus(new Vector2D(3, 4));
expect(v2.x).to.equal(-2);
expect(v2.y).to.equal(-2);
});
it("should scale vectors", () => {
const v2 = v.scale(3);
expect(v2.x).to.equal(3);
expect(v2.y).to.equal(6);
});
it("should compare vectors", () => {
expect(v.equals(v.scale(1))).to.be.true;
expect(v.equals(v.scale(2))).to.be.false;
});
});

33
ui/tsconfig.json Normal file
View file

@ -0,0 +1,33 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"experimentalDecorators": true,
"lib": [
"es2020",
"dom",
"dom.iterable",
"es2022.object",
"es2023.array",
],
// "moduleResolution": "node",
// "sourceMap": true,
"baseUrl": ".",
"types": [
"webpack-env",
"mocha",
"chai",
],
"paths": {
"@/*": [
"src/*",
]
},
},
"include": [
"src/**/*.vue",
"src/**/*.ts",
// "src/**/*.tsx",
"tests/**/*.ts",
// "tests/**/*.tsx",
],
}

26
ui/vue.config.js Normal file
View file

@ -0,0 +1,26 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const { defineConfig } = require("@vue/cli-service");
const webpack = require("webpack");
module.exports = defineConfig({
transpileDependencies: true,
devServer: {
host: "127.0.0.1",
},
pages: {
index: {
entry: "src/main.ts",
title: "Kalender-Gewinnspiel",
},
},
// https://stackoverflow.com/a/77765007
configureWebpack: {
plugins: [
new webpack.DefinePlugin({
// Vue CLI is in maintenance mode, and probably won't merge my PR to fix this in their tooling
// https://github.com/vuejs/vue-cli/pull/7443
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
}),
],
},
});

11053
ui/yarn.lock Normal file

File diff suppressed because it is too large Load diff