Compare commits

..

No commits in common. "feature/refactoring" and "master" have entirely different histories.

85 changed files with 0 additions and 13985 deletions

View file

@ -1,27 +0,0 @@
# 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*

View file

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

View file

@ -1,47 +0,0 @@
############
# build ui #
############
ARG NODE_VERSION=18.16
ARG PYTHON_VERSION=3.11-slim
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 yarn install --production false
# copy and build advent22_ui
COPY ui ./
RUN yarn build --dest /tmp/advent22_ui/html
###########
# web app #
###########
ARG PYTHON_VERSION
FROM tiangolo/uvicorn-gunicorn:python${PYTHON_VERSION} AS production
# env setup
WORKDIR /usr/local/src/advent22_api
ENV \
PRODUCTION_MODE="true" \
PORT="8000" \
MODULE_NAME="advent22_api.app"
EXPOSE 8000
# install advent22_api
COPY api ./
RUN set -ex; \
# remove example app
rm -rf /app; \
\
python -m pip --no-cache-dir install ./
# add prepared advent22_ui
COPY --from=build-ui /tmp/advent22_ui /usr/local/share/advent22_ui
# run as unprivileged user
USER nobody

View file

@ -1,23 +0,0 @@
# 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

@ -1,50 +0,0 @@
// 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/vscode/devcontainers/python:1-3.11-bookworm",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers-contrib/features/poetry:2": {},
"ghcr.io/devcontainers-contrib/features/apt-get-packages:1": {
"packages": "git-flow, git-lfs"
},
"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"
]
}
},
"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"
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "pip3 install --user -r requirements.txt",
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View file

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

View file

@ -1,22 +0,0 @@
{
// 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,
}
]
}

View file

@ -1,20 +0,0 @@
{
"python.languageServer": "Pylance",
"editor.formatOnSave": true,
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
},
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"git.closeDiffOnOperation": true,
"python.analysis.typeCheckingMode": "basic",
"python.analysis.diagnosticMode": "workspace",
"python.testing.pytestArgs": [
"test"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"black-formatter.importStrategy": "fromEnvironment",
"flake8.importStrategy": "fromEnvironment",
}

View file

@ -1,46 +0,0 @@
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

@ -1,138 +0,0 @@
import colorsys
from dataclasses import dataclass
from typing import Self, TypeAlias, cast
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from .config import Config
_RGB: TypeAlias = tuple[int, int, int]
_XY: TypeAlias = tuple[float, float]
@dataclass(slots=True, frozen=True)
class AdventImage:
img: Image.Image
@classmethod
async def from_img(cls, img: Image.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=Image.LANCZOS,
)
)
async def get_text_box(
self,
xy: _XY,
text: str | bytes,
font: "ImageFont._Font",
anchor: str | None = "mm",
**text_kwargs,
) -> "Image._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 = Image.new(mode="1", size=self.img.size, color=0)
# 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: "Image._Box",
) -> tuple[int, int, int]:
"""
Durchschnittsfarbe eines rechteckigen Ausschnitts in
einem Bild berechnen
"""
pixel_data = self.img.crop(box).getdata()
mean_color: np.ndarray = np.mean(pixel_data, axis=0)
return cast(_RGB, tuple(mean_color.astype(int)))
async def hide_text(
self,
xy: _XY,
text: str | bytes,
font: "ImageFont._Font",
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 not None:
# 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 = tuple(int(val) for val in text_color)
# Buchstaben verstecken
ImageDraw.Draw(self.img).text(
xy=xy,
text=text,
font=font,
fill=cast(_RGB, text_color),
anchor=anchor,
**text_kwargs,
)

View file

@ -1,55 +0,0 @@
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

@ -1,86 +0,0 @@
import tomllib
from markdown import markdown
from pydantic import BaseModel, ConfigDict, field_validator
from .dav.webdav import WebDAV
from .settings import SETTINGS
from .transformed_string import TransformedString
class User(BaseModel):
name: str
password: str
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: User
# 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

@ -1,61 +0,0 @@
from json import JSONDecodeError
from typing import Callable, Hashable
import requests
from cachetools.keys import hashkey
from CacheToolsUtils import RedisCache as __RedisCache
from redis.commands.core import ResponseT
from redis.typing import EncodableT
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

@ -1,108 +0,0 @@
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.username,
"webdav_password": SETTINGS.webdav.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

@ -1,214 +0,0 @@
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 Image, ImageFont
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,
)
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_image_names(
auto_image_names: dict[int, str] = Depends(get_all_auto_image_names),
manual_image_names: list[str] = Depends(list_images_manual),
) -> dict[int, str]:
"""
Bilder "auto" und "manual" zu Tagen zuordnen
"""
num_re = re.compile(r"/(\d+)\.", flags=re.IGNORECASE)
for name in manual_image_names:
assert (num_match := num_re.search(name)) is not None
auto_image_names[int(num_match.group(1))] = name
return auto_image_names
@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) -> "ImageFont._Font":
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.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),
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.Image | None:
"""
Bild für einen Tag abrufen
"""
if day not in days:
return None
try:
# Versuche, aus "manual"-Ordner zu laden
img = await load_image(f"images_manual/{day}.jpg")
# Als AdventImage verarbeiten
image = await AdventImage.from_img(img, cfg)
return image.img
except 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

@ -1,216 +0,0 @@
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 fastapi.responses import StreamingResponse
from PIL import Image
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.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 Image.open(BytesIO(await WebDAV.read_bytes(file_name)))
async def api_return_ico(img: Image.Image) -> StreamingResponse:
"""
ICO-Bild mit API zurückgeben
"""
# JPEG-Daten in Puffer speichern
img_buffer = BytesIO()
img.resize(size=(256, 256), resample=Image.LANCZOS)
img.save(img_buffer, format="ICO")
img_buffer.seek(0)
# zurückgeben
return StreamingResponse(
media_type="image/x-icon",
content=img_buffer,
)
async def api_return_jpeg(img: Image.Image) -> StreamingResponse:
"""
JPEG-Bild mit API zurückgeben
"""
# JPEG-Daten in Puffer speichern
img_buffer = BytesIO()
img.save(img_buffer, format="JPEG", quality=85)
img_buffer.seek(0)
# zurückgeben
return StreamingResponse(
media_type="image/jpeg",
content=img_buffer,
)
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

@ -1,93 +0,0 @@
from typing import TypeVar
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
T = TypeVar("T")
class DavSettings(BaseModel):
"""
Connection to a DAV server.
"""
protocol: str = "https"
host: str = "example.com"
path: str = "/remote.php/webdav"
prefix: str = "/advent22"
username: str = "advent22_user"
password: str = "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

@ -1,96 +0,0 @@
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

View file

@ -1,22 +0,0 @@
#!/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

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

View file

@ -1,65 +0,0 @@
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.name.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

@ -1,193 +0,0 @@
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, 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("/dav_credentials")
async def get_dav_credentials(
_: None = Depends(require_admin),
) -> tuple[str, str]:
"""
Zugangsdaten für WebDAV
"""
return SETTINGS.webdav.username, SETTINGS.webdav.password
@router.get("/ui_credentials")
async def get_ui_credentials(
_: None = Depends(require_admin),
cfg: Config = Depends(get_config),
) -> tuple[str, str]:
"""
Zugangsdaten für Admin-UI
"""
return cfg.admin.name, cfg.admin.password

View file

@ -1,109 +0,0 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from PIL 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, 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",
response_class=StreamingResponse,
)
async def get_background_image(
cal_cfg: CalendarConfig = Depends(get_calendar_config),
) -> StreamingResponse:
"""
Hintergrundbild laden
"""
return await api_return_jpeg(await load_image(f"files/{cal_cfg.background}"))
@router.get(
"/favicon",
response_class=StreamingResponse,
)
async def get_favicon(
cal_cfg: CalendarConfig = Depends(get_calendar_config),
) -> StreamingResponse:
"""
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}",
response_class=StreamingResponse,
)
async def get_image_for_day(
user_can_view: bool = Depends(user_can_view_day),
is_admin: bool = Depends(user_is_admin),
image: Image.Image | None = Depends(get_day_image),
) -> StreamingResponse:
"""
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)

1475
api/poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,33 +0,0 @@
[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 = "^10.2.0"
asyncify = "^0.9.2"
cachetools = "^5.3.3"
cachetoolsutils = "^8.5"
fastapi = "^0.103.1"
markdown = "^3.6"
numpy = "^1.26.4"
pydantic-settings = "^2.2.1"
python = ">=3.11,<3.13"
redis = {extras = ["hiredis"], version = "^5.0.3"}
tomli-w = "^1.0.0"
uvicorn = {extras = ["standard"], version = "^0.23.2"}
webdavclient3 = "^3.14.6"
[tool.poetry.group.dev.dependencies]
black = "^24.3.0"
flake8 = "^7.0.0"
pytest = "^8.1.1"
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core>=1.0.0"]

View file

@ -1,88 +0,0 @@
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)

View file

@ -1,77 +0,0 @@
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]

View file

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

View file

@ -1,39 +0,0 @@
// 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/vscode/devcontainers/javascript-node:1-18-bookworm",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers-contrib/features/apt-get-packages:1": {
"packages": "git-flow, git-lfs"
},
"ghcr.io/devcontainers-contrib/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 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
"postStartCommand": "yarn install --production false",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node"
}

View file

@ -1,35 +0,0 @@
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,
},
},
],
};

23
ui/.gitignore vendored
View file

@ -1,23 +0,0 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
# .vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

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

View file

@ -1,15 +0,0 @@
{
// 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}"
}
]
}

View file

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

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

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

View file

@ -1,24 +0,0 @@
# 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/).

View file

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

View file

@ -1,45 +0,0 @@
{
"name": "advent22_ui",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8080",
"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"
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/vue-fontawesome": "^3.0.8",
"@types/chai": "^4.3.17",
"@types/luxon": "^3.4.2",
"@types/mocha": "^10.0.7",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0",
"@vue/cli-plugin-unit-mocha": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.6",
"animate.css": "^4.1.1",
"axios": "^1.7.3",
"bulma": "^1.0.2",
"bulma-toast": "2.4.3",
"chai": "^5.1.1",
"core-js": "^3.38.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.27.0",
"luxon": "^3.5.0",
"pinia": "^2.2.1",
"sass": "^1.77.8",
"sass-loader": "^16.0.0",
"typescript": "~5.5.4",
"vue": "^3.4.37",
"vue-class-component": "^8.0.0-0"
},
"dependencies": {}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

View file

@ -1,32 +0,0 @@
<!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>

View file

@ -1,78 +0,0 @@
<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="tag is-warning" />
</div>
<div class="level-item">
<AdminButton class="tag is-link is-outlined" />
</div>
</div>
</div>
</footer>
</template>
<script lang="ts">
import { Options, Vue } from "vue-class-component";
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";
@Options({
components: {
AdminView,
AdminButton,
TouchButton,
UserView,
},
})
export default class extends Vue {
public readonly 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)

Binary file not shown.

View file

@ -1,14 +0,0 @@
@charset "utf-8";
//=====================
// custom color scheme
//=====================
$colors: (
"primary": #945DE1,
"link": #64B4BD,
"info": #8C4E80,
"success": #7E8E2B,
"warning": #F6CA6B,
"danger": #C5443B,
);

View file

@ -1,57 +0,0 @@
<template>
<LoginModal v-if="modal_visible" @submit="on_submit" @cancel="on_cancel" />
<BulmaButton
v-bind="$attrs"
:icon="'fa-solid fa-toggle-' + (store.is_admin ? 'on' : 'off')"
:busy="is_busy"
text="Admin"
@click.left="on_click"
/>
</template>
<script lang="ts">
import { Credentials } from "@/lib/model";
import { advent22Store } from "@/lib/store";
import { Options, Vue } from "vue-class-component";
import { APIError } from "@/lib/api_error";
import BulmaButton from "./bulma/Button.vue";
import LoginModal from "./LoginModal.vue";
@Options({
components: {
BulmaButton,
LoginModal,
},
})
export default class extends Vue {
public modal_visible = false;
public is_busy = false;
public readonly store = advent22Store();
public on_click() {
if (this.store.is_admin) {
this.store.logout();
} else {
// show login modal
this.is_busy = true;
this.modal_visible = true;
}
}
public on_submit(creds: Credentials) {
this.modal_visible = false;
this.store
.login(creds)
.catch((error) => APIError.alert(error))
.finally(() => (this.is_busy = false));
}
public on_cancel() {
this.modal_visible = false;
this.is_busy = false;
}
}
</script>

View file

@ -1,131 +0,0 @@
<template>
<MultiModal @handle="modal_handle" />
<BulmaToast @handle="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="_ensure_loaded(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 lang="ts">
import { API } from "@/lib/api";
import { APIError } from "@/lib/api_error";
import { ensure_loaded, Loading, name_door } from "@/lib/helpers";
import { ImageData } from "@/lib/model";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store";
import { Options, Vue } from "vue-class-component";
import MultiModal from "./MultiModal.vue";
import BulmaButton from "./bulma/Button.vue";
import BulmaToast from "./bulma/Toast.vue";
import CalendarDoor from "./calendar/CalendarDoor.vue";
import ThouCanvas from "./calendar/ThouCanvas.vue";
@Options({
components: {
MultiModal,
BulmaButton,
BulmaToast,
ThouCanvas,
CalendarDoor,
},
props: {
doors: Array,
},
})
export default class extends Vue {
public readonly doors!: Door[];
public readonly store = advent22Store();
private multi_modal?: MultiModal;
public toast?: typeof BulmaToast;
private toast_timeout?: number;
public modal_handle(modal: MultiModal) {
this.multi_modal = modal;
}
public toast_handle(toast: typeof BulmaToast) {
this.toast = toast;
if (this.store.is_touch_device) return;
this.store.when_initialized(() => {
this.toast_timeout = setTimeout(() => {
if (this.store.user_doors.length === 0) return;
if (this.store.is_touch_device) return;
this.toast!.show({ duration: 600000, type: "is-warning" });
}, 10e3);
});
}
public async door_click(day: number) {
if (this.toast_timeout !== undefined) clearTimeout(this.toast_timeout);
this.toast?.hide();
if (this.multi_modal === undefined) return;
this.multi_modal.show_progress();
try {
const day_image = await API.request<ImageData>(`user/image_${day}`);
this.multi_modal!.show_image(day_image.data_url, name_door(day));
} catch (error) {
APIError.alert(error);
this.multi_modal!.hide();
}
}
public beforeUnmount(): void {
this.toast?.hide();
}
public _ensure_loaded<T>(o: Loading<T>): T {
return ensure_loaded(o);
}
public _name_door(day: number): string {
return name_door(day);
}
}
</script>

View file

@ -1,55 +0,0 @@
<template>
{{ string_repr }}
</template>
<script lang="ts">
import { Duration } from "luxon";
import { Options, Vue } from "vue-class-component";
@Options({
props: {
until: Number,
tick_time: {
type: Number,
default: 200,
},
},
})
export default class extends Vue {
private until!: number;
private tick_time!: number;
private interval_id: number | null = null;
public string_repr = "";
private tick(): void {
const distance_ms = this.until - Date.now();
if (distance_ms <= 0) {
this.string_repr = "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) {
this.string_repr = d_days.toHuman() + " ";
} else {
this.string_repr = "";
}
this.string_repr += d_hms.toFormat("hh:mm:ss");
}
public mounted(): void {
this.tick();
this.interval_id = window.setInterval(this.tick, this.tick_time);
}
public beforeUnmount(): void {
if (this.interval_id === null) return;
window.clearInterval(this.interval_id);
}
}
</script>

View file

@ -1,94 +0,0 @@
<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="username"
/>
</div>
</div>
<div class="field">
<label class="label">Passwort</label>
<div class="control">
<input class="input" type="password" v-model="password" />
</div>
</div>
</section>
<footer class="modal-card-foot is-flex is-justify-content-space-around">
<BulmaButton
class="is-success"
@click.left="submit"
icon="fa-solid fa-unlock"
text="Login"
/>
<BulmaButton
class="is-danger"
@click.left="cancel"
icon="fa-solid fa-circle-xmark"
text="Abbrechen"
/>
</footer>
</div>
</div>
</template>
<script lang="ts">
import { Options, Vue } from "vue-class-component";
import BulmaButton from "./bulma/Button.vue";
@Options({
components: {
BulmaButton,
},
props: {
visible: Boolean,
},
emits: ["cancel", "submit"],
})
export default class extends Vue {
public username = "";
public password = "";
private on_keydown(e: KeyboardEvent) {
if (e.key == "Enter") this.submit();
else if (e.key == "Escape") this.cancel();
}
public mounted(): void {
window.addEventListener("keydown", this.on_keydown);
this.$nextTick(() => {
if (!(this.$refs.username_input instanceof HTMLElement)) return;
this.$refs.username_input.focus();
});
}
public beforeUnmount(): void {
window.removeEventListener("keydown", this.on_keydown);
}
public submit(): void {
this.$emit("submit", [this.username, this.password]);
}
public cancel(): void {
this.$emit("cancel");
}
}
</script>

View file

@ -1,83 +0,0 @@
<template>
<div class="modal is-active" v-if="active" @click="dismiss()">
<div class="modal-background" />
<div class="modal-content" style="max-height: 100vh; max-width: 95vw">
<template v-if="progress">
<progress class="progress is-primary" max="100" />
</template>
<template v-else>
<figure>
<figcaption class="tag is-primary">
{{ caption }}
</figcaption>
<div class="image is-square">
<img :src="image_src" alt="Kalender-Bild" />
</div>
</figure>
</template>
</div>
<button
v-if="!progress"
class="modal-close is-large has-background-primary"
/>
</div>
</template>
<script lang="ts">
import { Options, Vue } from "vue-class-component";
@Options({
emits: ["handle"],
})
export default class extends Vue {
public active = false;
public progress = false;
public image_src = "";
public caption = "";
private on_keydown(e: KeyboardEvent) {
if (e.key == "Escape") this.dismiss();
}
public created(): void {
this.$emit("handle", this);
}
public mounted(): void {
window.addEventListener("keydown", this.on_keydown);
}
public beforeUnmount(): void {
window.removeEventListener("keydown", this.on_keydown);
}
public show() {
this.active = true;
}
public hide() {
this.active = false;
}
public dismiss() {
// Cannot dismiss the "loading" screen
if (this.active && this.progress) return;
this.active = false;
}
public show_image(src: string, caption: string = "") {
this.progress = false;
this.image_src = src;
this.caption = caption;
this.show();
}
public show_progress() {
this.progress = true;
this.show();
}
}
</script>

View file

@ -1,28 +0,0 @@
<template>
<span>Eingabemodus:&nbsp;</span>
<BulmaButton
v-bind="$attrs"
:icon="
'fa-solid fa-' +
(store.is_touch_device ? 'hand-pointer' : 'arrow-pointer')
"
:text="store.is_touch_device ? 'Touch' : 'Desktop'"
@click.left="store.toggle_touch_device"
/>
</template>
<script lang="ts">
import { advent22Store } from "@/lib/store";
import { Options, Vue } from "vue-class-component";
import BulmaButton from "./bulma/Button.vue";
@Options({
components: {
BulmaButton,
},
})
export default class extends Vue {
public readonly store = advent22Store();
}
</script>

View file

@ -1,38 +0,0 @@
<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 lang="ts">
import { advent22Store } from "@/lib/store";
import { Options, Vue } from "vue-class-component";
import Calendar from "./Calendar.vue";
import CountDown from "./CountDown.vue";
@Options({
components: {
Calendar,
CountDown,
},
})
export default class extends Vue {
public readonly store = advent22Store();
}
</script>

View file

@ -1,22 +0,0 @@
<template>
<ConfigView />
<CalendarAssistant />
<DoorMapEditor />
</template>
<script lang="ts">
import { Options, Vue } from "vue-class-component";
import CalendarAssistant from "./CalendarAssistant.vue";
import ConfigView from "./ConfigView.vue";
import DoorMapEditor from "./DoorMapEditor.vue";
@Options({
components: {
ConfigView,
CalendarAssistant,
DoorMapEditor,
},
})
export default class extends Vue {}
</script>

View file

@ -1,118 +0,0 @@
<template>
<MultiModal @handle="modal_handle" />
<BulmaDrawer header="Kalender-Assistent" @open="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="fa-solid fa-door-open"
:text="day"
@click.left="door_click(day)"
/>
</div>
</div>
</div>
</BulmaDrawer>
</template>
<script lang="ts">
import { API } from "@/lib/api";
import { name_door, objForEach } from "@/lib/helpers";
import { ImageData, NumStrDict } from "@/lib/model";
import { Options, Vue } from "vue-class-component";
import MultiModal from "../MultiModal.vue";
import BulmaButton from "../bulma/Button.vue";
import BulmaDrawer from "../bulma/Drawer.vue";
@Options({
components: {
BulmaButton,
BulmaDrawer,
MultiModal,
},
})
export default class extends Vue {
public day_data: {
[day: number]: {
part: string;
image_name: string;
};
} = {};
private multi_modal?: MultiModal;
public modal_handle(modal: MultiModal) {
this.multi_modal = modal;
}
public on_open(ready: () => void, fail: () => void): void {
Promise.all([
API.request<NumStrDict>("admin/day_parts"),
API.request<NumStrDict>("admin/day_image_names"),
])
.then(([day_parts, day_image_names]) => {
const _ensure_day_in_data = (day: number) => {
if (!(day in this.day_data)) {
this.day_data[day] = { part: "", image_name: "" };
}
};
objForEach(day_parts, (day, part) => {
_ensure_day_in_data(day);
this.day_data[day].part = part;
});
objForEach(day_image_names, (day, image_name) => {
_ensure_day_in_data(day);
this.day_data[day].image_name = image_name;
});
ready();
})
.catch(fail);
}
public async door_click(day: number) {
if (this.multi_modal === undefined) return;
this.multi_modal.show_progress();
try {
const day_image = await API.request<ImageData>(`user/image_${day}`);
this.multi_modal!.show_image(day_image.data_url, name_door(day));
} catch (error) {
this.multi_modal!.hide();
}
}
}
</script>

View file

@ -1,292 +0,0 @@
<template>
<BulmaDrawer header="Konfiguration" @open="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 @load="load_dav_credentials">
<span class="tag is-danger">user</span>
{{ dav_credentials[0] }}
<br />
<span class="tag is-danger">pass</span>
{{ dav_credentials[1] }}
</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 @load="load_ui_credentials">
<span class="tag is-danger">user</span>
{{ ui_credentials[0] }}
<br />
<span class="tag is-danger">pass</span>
{{ ui_credentials[1] }}
</BulmaSecret>
</dd>
</dl>
</div>
</div>
</div>
</div>
</BulmaDrawer>
</template>
<script lang="ts">
import { AdminConfigModel, Credentials, DoorSaved } from "@/lib/model";
import { advent22Store } from "@/lib/store";
import { DateTime } from "luxon";
import { Options, Vue } from "vue-class-component";
import { API } from "@/lib/api";
import BulmaDrawer from "../bulma/Drawer.vue";
import BulmaSecret from "../bulma/Secret.vue";
import CountDown from "../CountDown.vue";
@Options({
components: {
BulmaDrawer,
BulmaSecret,
CountDown,
},
})
export default class extends Vue {
public readonly store = advent22Store();
public admin_config_model: 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",
},
};
public doors: DoorSaved[] = [];
public dav_credentials: Credentials = ["", ""];
public ui_credentials: Credentials = ["", ""];
public fmt_puzzle_date(name: keyof AdminConfigModel["puzzle"]): string {
const iso_date = this.admin_config_model.puzzle[name];
if (!(typeof iso_date == "string")) return "-";
return DateTime.fromISO(iso_date).toLocaleString(DateTime.DATE_SHORT);
}
public on_open(ready: () => void, fail: () => void): void {
Promise.all([
this.store.update(),
API.request<AdminConfigModel>("admin/config_model"),
API.request<DoorSaved[]>("admin/doors"),
])
.then(([store_update, admin_config_model, doors]) => {
store_update; // discard value
this.admin_config_model = admin_config_model;
this.doors = doors;
ready();
})
.catch(fail);
}
public load_dav_credentials(): void {
API.request<Credentials>("admin/dav_credentials")
.then((creds) => (this.dav_credentials = creds))
.catch(() => {});
}
public load_ui_credentials(): void {
API.request<Credentials>("admin/ui_credentials")
.then((creds) => (this.ui_credentials = creds))
.catch(() => {});
}
}
</script>
<style scoped>
dd {
overflow-x: auto;
}
</style>

View file

@ -1,196 +0,0 @@
<template>
<BulmaDrawer header="Türchen bearbeiten" @open="on_open">
<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="fa-solid 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="fa-solid 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" :doors="doors" />
<DoorChooser v-if="current_step === 1" :doors="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="fa-solid fa-cloud-arrow-down"
:busy="loading_doors"
text="Laden"
/>
<BulmaButton
class="card-footer-item is-warning"
@click="on_discard"
icon="fa-solid fa-trash"
text="Löschen"
/>
<BulmaButton
class="card-footer-item is-success"
@click="on_upload"
icon="fa-solid fa-cloud-arrow-up"
:busy="saving_doors"
text="Speichern"
/>
</footer>
</BulmaDrawer>
</template>
<script lang="ts">
import { Step } from "@/lib/helpers";
import { DoorSaved } from "@/lib/model";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store";
import { Options, Vue } from "vue-class-component";
import { API } from "@/lib/api";
import { APIError } from "@/lib/api_error";
import { toast } from "bulma-toast";
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";
@Options({
components: {
BulmaBreadcrumbs,
BulmaButton,
BulmaDrawer,
DoorPlacer,
DoorChooser,
Calendar,
},
})
export default class extends Vue {
public readonly steps: Step[] = [
{ label: "Platzieren", icon: "fa-solid fa-crosshairs" },
{ label: "Ordnen", icon: "fa-solid fa-list-ol" },
{ label: "Vorschau", icon: "fa-solid fa-magnifying-glass" },
];
public current_step = 0;
public doors: Door[] = [];
private readonly store = advent22Store();
public loading_doors = false;
public saving_doors = false;
private load_doors(): Promise<void> {
return new Promise<void>((resolve, reject) => {
API.request<DoorSaved[]>("admin/doors")
.then((data) => {
this.doors.length = 0;
for (const value of data) {
this.doors.push(Door.load(value));
}
resolve();
})
.catch((error) => {
APIError.alert(error);
reject();
});
});
}
private save_doors(): Promise<void> {
return new Promise<void>((resolve, reject) => {
const data: DoorSaved[] = [];
for (const door of this.doors) {
data.push(door.save());
}
API.request<void>({ endpoint: "admin/doors", method: "PUT", data: data })
.then(resolve)
.catch((error) => {
APIError.alert(error);
reject();
});
});
}
public on_open(ready: () => void, fail: () => void): void {
this.load_doors().then(ready).catch(fail);
}
public on_download() {
if (confirm("Aktuelle Änderungen verwerfen und Status vom Server laden?")) {
this.loading_doors = true;
this.load_doors()
.then(() =>
toast({
message: "Erfolgreich!",
type: "is-success",
duration: 2e3,
}),
)
.catch(() => {})
.finally(() => (this.loading_doors = false));
}
}
public on_discard() {
if (confirm("Alle Türchen löschen? (nur lokal)")) {
// empty `doors` array
this.doors.length = 0;
}
}
public on_upload() {
if (confirm("Aktuelle Änderungen an den Server schicken?")) {
this.saving_doors = true;
this.save_doors()
.then(() => {
this.load_doors()
.then(() =>
toast({
message: "Erfolgreich!",
type: "is-success",
duration: 2e3,
}),
)
.catch(() => {})
.finally(() => (this.saving_doors = false));
})
.catch(() => (this.saving_doors = false));
}
}
}
</script>

View file

@ -1,39 +0,0 @@
<!-- 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="modelValue === index ? 'is-active' : ''"
@click.left="change_step(index)"
>
<a>
<span class="icon is-small">
<FontAwesomeIcon :icon="step.icon" />
</span>
<span>{{ step.label }}</span>
</a>
</li>
</ul>
</nav>
</template>
<script setup lang="ts">
import { Step } from "@/lib/helpers";
const props = defineProps<{
steps: Step[];
modelValue: number;
}>();
const emit = defineEmits<{
"update:modelValue": [number];
}>();
function change_step(next_step: number) {
if (next_step === props.modelValue) return;
emit("update:modelValue", next_step);
}
</script>

View file

@ -1,28 +0,0 @@
<!-- 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

@ -1,89 +0,0 @@
<!-- 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" class="card-header-icon px-0">
<BulmaButton class="is-small is-primary" @click="refresh">
<FontAwesomeIcon
:icon="['fas', 'arrows-rotate']"
:spin="is_open && 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>
<template v-if="is_open">
<div v-if="state === 'loading'" class="card-content">
<progress class="progress is-primary" />
</div>
<div
v-else-if="state === 'failed'"
class="card-content has-text-danger has-text-centered"
>
<span class="icon is-large">
<FontAwesomeIcon :icon="['fas', 'ban']" size="3x" />
</span>
</div>
<slot v-else name="default" />
</template>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import BulmaButton from "./Button.vue";
withDefaults(
defineProps<{
header: string;
refreshable?: boolean;
}>(),
{ refreshable: false },
);
const emit = defineEmits<{
open: [
{
ready(): void;
fail(): void;
},
];
}>();
const is_open = ref(false);
const state = ref<"loading" | "ready" | "failed">("loading");
function toggle() {
is_open.value = !is_open.value;
if (is_open.value) {
state.value = "loading";
emit("open", {
ready: () => (state.value = "ready"),
fail: () => (state.value = "failed"),
});
}
}
function refresh() {
is_open.value = false;
toggle();
}
</script>
<style scoped>
div.card:not(:last-child) {
margin-bottom: 1.5rem;
}
</style>

View file

@ -1,47 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<slot v-if="state === 'show'" name="default" />
<span v-else>***</span>
<BulmaButton
:class="`is-small is-${button_class} ml-2`"
:icon="['fas', `${button_icon}`]"
:busy="state === 'click'"
@click="on_click"
/>
</template>
<script setup lang="ts">
import { ref } from "vue";
import BulmaButton from "./Button.vue";
const emit = defineEmits<{
load: [];
}>();
const state = ref<"start" | "click" | "show">("start");
const button_class = ref<"primary" | "warning" | "danger">("primary");
const button_icon = ref<"eye-slash" | "eye">("eye-slash");
function on_click(): void {
switch (state.value) {
case "show":
state.value = "start";
button_class.value = "primary";
button_icon.value = "eye-slash";
break;
case "start":
state.value = "click";
button_class.value = "warning";
button_icon.value = "eye-slash";
break;
case "click":
state.value = "show";
button_class.value = "danger";
button_icon.value = "eye";
emit("load");
break;
}
}
</script>

View file

@ -1,49 +0,0 @@
<!-- 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 * as bulmaToast from "bulma-toast";
import { onMounted, ref } from "vue";
const emit = defineEmits<{
handle: [
{
show(options: bulmaToast.Options): void;
hide(): void;
},
];
}>();
const message = ref<HTMLDivElement | null>(null);
onMounted(() =>
emit("handle", {
show(options: bulmaToast.Options = {}) {
if (!(message.value instanceof HTMLElement)) return;
bulmaToast.toast({
...options,
single: true,
message: message.value,
});
},
hide() {
if (!(message.value instanceof HTMLElement)) return;
const toast_div = message.value.parentElement;
if (!(toast_div instanceof HTMLDivElement)) return;
const dbutton = toast_div.querySelector("button.delete");
if (!(dbutton instanceof HTMLButtonElement)) return;
dbutton.click();
},
}),
);
</script>

View file

@ -1,41 +0,0 @@
<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 lang="ts">
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store";
import { Options, Vue } from "vue-class-component";
import SVGRect from "./SVGRect.vue";
@Options({
components: {
SVGRect,
},
props: {
door: Door,
force_visible: {
type: Boolean,
default: false,
},
},
})
export default class extends Vue {
public readonly store = advent22Store();
public door!: Door;
public force_visible!: boolean;
}
</script>

View file

@ -1,76 +0,0 @@
<template>
<foreignObject
:x="Math.round(get_bg_aspect_ratio() * rectangle.left)"
:y="rectangle.top"
:width="Math.round(get_bg_aspect_ratio() * rectangle.width)"
:height="rectangle.height"
:style="`transform: scaleX(${1 / get_bg_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 { loading_success } from "@/lib/helpers";
import { Rectangle } from "@/lib/rects/rectangle";
import { advent22Store } from "@/lib/store";
const store = advent22Store();
type BulmaVariant =
| "primary"
| "link"
| "info"
| "success"
| "warning"
| "danger";
withDefaults(
defineProps<{
variant: BulmaVariant;
visible?: boolean;
rectangle: Rectangle;
}>(),
{
visible: true,
},
);
function get_bg_aspect_ratio(): number {
if (!loading_success(store.background_image)) return 1;
return store.background_image.height / store.background_image.width;
}
</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

@ -1,62 +0,0 @@
<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 TCMouseEvent = [MouseEvent, Vector2D];
const emit = defineEmits<{
mousedown: TCMouseEvent;
mouseup: TCMouseEvent;
mousemove: TCMouseEvent;
click: TCMouseEvent;
dblclick: TCMouseEvent;
}>();
function transform_mouse_event(event: MouseEvent) {
const point = get_event_thous(event);
// mute a useless typescript error
const event_type = event.type as "mousedown";
emit(event_type, event, point);
}
</script>
<style scoped>
svg {
height: 100%;
width: 100%;
position: absolute;
left: 0;
top: 0;
z-index: auto;
}
</style>

View file

@ -1,157 +0,0 @@
<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 doors"
:key="`door-${index}`"
:door="door"
force_visible
/>
<SVGRect
v-if="preview_visible"
variant="success"
:rectangle="preview_rect"
visible
/>
</ThouCanvas>
</template>
<script lang="ts">
import { Door } from "@/lib/rects/door";
import { Rectangle } from "@/lib/rects/rectangle";
import { Vector2D } from "@/lib/rects/vector2d";
import { Options, Vue } from "vue-class-component";
import CalendarDoor from "../calendar/CalendarDoor.vue";
import SVGRect from "../calendar/SVGRect.vue";
import ThouCanvas from "../calendar/ThouCanvas.vue";
enum CanvasState {
Idle,
Drawing,
Dragging,
}
@Options({
components: {
CalendarDoor,
SVGRect,
ThouCanvas,
},
props: {
doors: Array,
},
})
export default class extends Vue {
private readonly min_rect_area = 300;
private state = CanvasState.Idle;
public preview_rect = new Rectangle();
private drag_door?: Door;
private drag_origin = new Vector2D();
public doors!: Door[];
public get preview_visible(): boolean {
return this.state !== CanvasState.Idle;
}
private pop_door(point: Vector2D): Door | undefined {
const idx = this.doors.findIndex((rect) => rect.position.contains(point));
if (idx === -1) {
return;
}
return this.doors.splice(idx, 1)[0];
}
public draw_start(event: MouseEvent, point: Vector2D) {
if (this.preview_visible) {
return;
}
this.state = CanvasState.Drawing;
this.preview_rect = new Rectangle(point, point);
}
public draw_finish() {
if (this.state !== CanvasState.Drawing || this.preview_rect === undefined) {
return;
}
this.state = CanvasState.Idle;
if (this.preview_rect.area < this.min_rect_area) {
return;
}
this.doors.push(new Door(this.preview_rect));
}
public drag_start(event: MouseEvent, point: Vector2D) {
if (this.preview_visible) {
return;
}
this.drag_door = this.pop_door(point);
if (this.drag_door === undefined) {
return;
}
this.state = CanvasState.Dragging;
this.drag_origin = point;
this.preview_rect = this.drag_door.position;
}
public drag_finish() {
if (
this.state !== CanvasState.Dragging ||
this.preview_rect === undefined
) {
return;
}
this.state = CanvasState.Idle;
this.doors.push(new Door(this.preview_rect, this.drag_door!.day));
}
public on_mousemove(event: MouseEvent, point: Vector2D) {
if (this.preview_rect === undefined) {
return;
}
if (this.state === CanvasState.Drawing) {
this.preview_rect = this.preview_rect.update(undefined, point);
} else if (this.state === CanvasState.Dragging && this.drag_door) {
const movement = point.minus(this.drag_origin);
this.preview_rect = this.drag_door.position.move(movement);
}
}
public remove_rect(event: MouseEvent, point: Vector2D) {
if (this.preview_visible) {
return;
}
this.pop_door(point);
}
}
</script>
<style lang="scss" scoped>
svg {
cursor: crosshair;
* {
pointer-events: none;
}
}
</style>

View file

@ -1,52 +0,0 @@
<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="_ensure_loaded(store.background_image).data_url" />
<ThouCanvas>
<PreviewDoor
v-for="(door, index) in doors"
:key="`door-${index}`"
:door="door"
/>
</ThouCanvas>
</figure>
</div>
</template>
<script lang="ts">
import { ensure_loaded, Loading } from "@/lib/helpers";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store";
import { Options, Vue } from "vue-class-component";
import ThouCanvas from "../calendar/ThouCanvas.vue";
import PreviewDoor from "./PreviewDoor.vue";
@Options({
components: {
ThouCanvas,
PreviewDoor,
},
props: {
doors: Array,
},
})
export default class extends Vue {
public doors!: Door[];
public readonly store = advent22Store();
public _ensure_loaded<T>(o: Loading<T>): T {
return ensure_loaded(o);
}
}
</script>

View file

@ -1,42 +0,0 @@
<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="_ensure_loaded(store.background_image).data_url" />
<DoorCanvas :doors="doors" />
</figure>
</div>
</template>
<script lang="ts">
import { ensure_loaded, Loading } from "@/lib/helpers";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store";
import { Options, Vue } from "vue-class-component";
import DoorCanvas from "./DoorCanvas.vue";
@Options({
components: {
DoorCanvas,
},
props: {
doors: Array,
},
})
export default class extends Vue {
public doors!: Door[];
public readonly store = advent22Store();
public _ensure_loaded<T>(o: Loading<T>): T {
return ensure_loaded(o);
}
}
</script>

View file

@ -1,89 +0,0 @@
<template>
<SVGRect
style="cursor: text"
:rectangle="door.position"
:variant="editing ? 'success' : 'primary'"
@click.left="on_click"
visible
>
<input
v-if="editing"
v-model="day_str"
ref="day_input"
class="input is-large"
type="number"
:min="MIN_DAY"
placeholder="Tag"
@keydown="on_keydown"
/>
<div v-else class="has-text-danger">
{{ door.day > 0 ? door.day : "*" }}
</div>
</SVGRect>
</template>
<script lang="ts">
import { Door } from "@/lib/rects/door";
import { Options, Vue } from "vue-class-component";
import SVGRect from "../calendar/SVGRect.vue";
@Options({
components: {
SVGRect,
},
props: {
door: Door,
},
})
export default class extends Vue {
public door!: Door;
public readonly MIN_DAY = Door.MIN_DAY;
public day_str = "";
public editing = false;
private toggle_editing() {
this.day_str = String(this.door.day);
this.editing = !this.editing;
}
public on_click(event: MouseEvent) {
if (!(event.target instanceof HTMLDivElement)) {
return;
}
if (!this.editing) {
const day_input_focus = () => {
if (this.$refs.day_input instanceof HTMLInputElement) {
this.$refs.day_input.select();
return;
}
this.$nextTick(day_input_focus);
};
day_input_focus();
} else {
this.door.day = this.day_str;
}
this.toggle_editing();
}
public on_keydown(event: KeyboardEvent) {
if (!this.editing) {
return;
}
if (event.key === "Enter") {
this.door.day = this.day_str;
this.toggle_editing();
} else if (event.key === "Delete") {
this.door.day = -1;
this.toggle_editing();
} else if (event.key === "Escape") {
this.toggle_editing();
}
}
}
</script>

View file

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

View file

@ -1,82 +0,0 @@
import axios, {
AxiosBasicCredentials,
type AxiosRequestConfig,
type Method,
type RawAxiosRequestHeaders,
} 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
console.warn("Unexpected NODE_ENV value");
}
// 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 storage_key = "advent22/credentials";
public static set creds(value: AxiosBasicCredentials | null) {
if (value === null) {
localStorage.removeItem(this.storage_key);
return;
}
localStorage.setItem(this.storage_key, JSON.stringify(value));
}
public static get creds(): AxiosBasicCredentials | undefined {
const auth_json = localStorage.getItem(this.storage_key);
if (auth_json !== null) return JSON.parse(auth_json);
}
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) {
console.error(`Failed to query ${p.endpoint}: ${reason}`);
throw new APIError(reason, p.endpoint);
}
}
}

View file

@ -1,77 +0,0 @@
import { AxiosError } from "axios";
import { toast } from "bulma-toast";
export class APIError extends Error {
reason: unknown;
axios_error: AxiosError | null = null;
constructor(reason: unknown, endpoint: string) {
super(endpoint); // sets this.message to the endpoint
this.reason = reason;
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 === null) 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() {
toast({
message: this.format(),
type: "is-danger",
});
}
public static alert(error: unknown) {
new APIError(error, "").alert();
}
}

View file

@ -1,44 +0,0 @@
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 Loading<T> = T | "loading" | "error";
export function loading_success<T>(o: Loading<T>): o is T {
if (o === "loading") return false;
if (o === "error") return false;
return true;
}
export function ensure_loaded<T>(o: Loading<T>): T {
if (!loading_success(o)) throw "";
return o;
}
export function handle_error(error: unknown) {
if (error instanceof APIError) {
error.alert();
} else {
console.error(error);
}
}
export function name_door(day: number): string {
return `Türchen ${day}`;
}
export interface Step {
label: string;
icon: string | string[];
}

View file

@ -1,66 +0,0 @@
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 {
height: number;
width: number;
data_url: string;
}
export type Credentials = [username: string, password: string];

View file

@ -1,52 +0,0 @@
import { 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: Rectangle);
constructor(position: Rectangle, day: number);
constructor(position: Rectangle, day = Door.MIN_DAY) {
this.day = day;
this.position = position;
}
public get day(): number {
return this._day;
}
public set day(day: unknown) {
// integer coercion
const result = Number(day);
if (isNaN(result)) {
this._day = Door.MIN_DAY;
} else {
this._day = Math.max(Math.floor(result), 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

@ -1,79 +0,0 @@
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

@ -1,27 +0,0 @@
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;
}
}

View file

@ -1,132 +0,0 @@
import { acceptHMRUpdate, defineStore } from "pinia";
import { API } from "./api";
import { Loading } from "./helpers";
import { 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({
id: "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"),
]);
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;
},
login(creds: Credentials): Promise<boolean> {
API.creds = { username: creds[0], password: creds[1] };
return this.update_is_admin();
},
logout(): Promise<boolean> {
return this.login(["", ""]);
},
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),
);
}

View file

@ -1,23 +0,0 @@
@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
//==============
@import "animate.css/animate";

View file

@ -1,27 +0,0 @@
import { advent22Store } from "@/lib/store";
import { FontAwesomePlugin } from "@/plugins/fontawesome";
import * as bulmaToast 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(FontAwesomePlugin);
app.use(createPinia());
advent22Store().init();
app.mount("#app");
bulmaToast.setDefaults({
duration: 10e3,
pauseOnHover: true,
dismissible: true,
closeOnClick: false,
type: "is-white",
position: "top-center",
animate: { in: "backInDown", out: "backOutUp" },
});

View file

@ -1,20 +0,0 @@
import { App, Plugin } from "vue";
/* 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);
/* import font awesome icon component */
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
export const FontAwesomePlugin: Plugin = {
install(app: App) {
app.component("font-awesome-icon", FontAwesomeIcon);
},
};

View file

@ -1,90 +0,0 @@
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,
) {
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

@ -1,41 +0,0 @@
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;
});
});

View file

@ -1,43 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"moduleResolution": "node",
"experimentalDecorators": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"sourceMap": true,
"baseUrl": ".",
"types": [
"webpack-env",
"mocha",
"chai"
],
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules"
]
}

View file

@ -1,25 +0,0 @@
const { defineConfig } = require("@vue/cli-service");
const webpack = require("webpack");
module.exports = defineConfig({
transpileDependencies: true,
devServer: {
host: "localhost",
},
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",
}),
],
},
});

File diff suppressed because it is too large Load diff