mirror of
https://code.lenaisten.de/Lenaisten/advent22.git
synced 2026-02-25 10:30:16 +00:00
Merge branch 'release/0.1.0'
This commit is contained in:
commit
79bf655cbb
92 changed files with 19143 additions and 0 deletions
27
.dockerignore
Normal file
27
.dockerignore
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# commonly found
|
||||||
|
**/.git
|
||||||
|
**/.idea
|
||||||
|
**/.DS_Store
|
||||||
|
**/.vscode
|
||||||
|
**/.devcontainer
|
||||||
|
|
||||||
|
**/dist
|
||||||
|
**/.gitignore
|
||||||
|
**/Dockerfile
|
||||||
|
**/.dockerignore
|
||||||
|
|
||||||
|
# found in python and JS dirs
|
||||||
|
**/__pycache__
|
||||||
|
**/node_modules
|
||||||
|
**/.pytest_cache
|
||||||
|
|
||||||
|
# env files
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/.env.*.local
|
||||||
|
|
||||||
|
# log files
|
||||||
|
**/npm-debug.log*
|
||||||
|
**/yarn-debug.log*
|
||||||
|
**/yarn-error.log*
|
||||||
|
**/pnpm-debug.log*
|
||||||
129
Dockerfile
Normal file
129
Dockerfile
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
ARG NODE_VERSION=24
|
||||||
|
ARG PYTHON_VERSION=3.14-slim
|
||||||
|
|
||||||
|
#############
|
||||||
|
# build api #
|
||||||
|
#############
|
||||||
|
|
||||||
|
ARG PYTHON_VERSION
|
||||||
|
FROM python:${PYTHON_VERSION} AS build-api
|
||||||
|
|
||||||
|
# env setup
|
||||||
|
WORKDIR /usr/local/src/advent22_api
|
||||||
|
ENV \
|
||||||
|
PATH="/root/.local/bin:${PATH}"
|
||||||
|
|
||||||
|
# install poetry with export plugin
|
||||||
|
RUN set -ex; \
|
||||||
|
\
|
||||||
|
python -m pip --no-cache-dir install --upgrade pip wheel; \
|
||||||
|
\
|
||||||
|
apt-get update; apt-get install --yes --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
; rm -rf /var/lib/apt/lists/*; \
|
||||||
|
\
|
||||||
|
curl -sSL https://install.python-poetry.org | python3 -; \
|
||||||
|
poetry self add poetry-plugin-export;
|
||||||
|
|
||||||
|
# build dependency wheels
|
||||||
|
COPY api/pyproject.toml api/poetry.lock ./
|
||||||
|
RUN set -ex; \
|
||||||
|
\
|
||||||
|
# # buildtime dependencies
|
||||||
|
# apt-get update; apt-get install --yes --no-install-recommends \
|
||||||
|
# build-essential \
|
||||||
|
# ; rm -rf /var/lib/apt/lists/*; \
|
||||||
|
\
|
||||||
|
# generate requirements.txt
|
||||||
|
poetry export \
|
||||||
|
--format requirements.txt \
|
||||||
|
--output requirements.txt; \
|
||||||
|
\
|
||||||
|
python3 -m pip --no-cache-dir wheel \
|
||||||
|
--wheel-dir ./dist \
|
||||||
|
--requirement requirements.txt;
|
||||||
|
|
||||||
|
# build advent22_api wheel
|
||||||
|
COPY api ./
|
||||||
|
RUN poetry build --format wheel --output ./dist
|
||||||
|
|
||||||
|
############
|
||||||
|
# build ui #
|
||||||
|
############
|
||||||
|
|
||||||
|
ARG NODE_VERSION
|
||||||
|
FROM node:${NODE_VERSION} AS build-ui
|
||||||
|
|
||||||
|
# env setup
|
||||||
|
WORKDIR /usr/local/src/advent22_ui
|
||||||
|
|
||||||
|
# install advent22_ui dependencies
|
||||||
|
COPY ui/package*.json ui/yarn*.lock ./
|
||||||
|
RUN set -ex; \
|
||||||
|
corepack enable; \
|
||||||
|
yarn install;
|
||||||
|
|
||||||
|
# copy and build advent22_ui
|
||||||
|
COPY ui ./
|
||||||
|
RUN set -ex; \
|
||||||
|
yarn dlx update-browserslist-db@latest; \
|
||||||
|
yarn build --dest /tmp/advent22_ui/html; \
|
||||||
|
# exclude webpack-bundle-analyzer output
|
||||||
|
rm -f /tmp/advent22_ui/html/report.html;
|
||||||
|
|
||||||
|
######################
|
||||||
|
# python preparation #
|
||||||
|
######################
|
||||||
|
|
||||||
|
ARG PYTHON_VERSION
|
||||||
|
FROM python:${PYTHON_VERSION} AS uvicorn-gunicorn
|
||||||
|
|
||||||
|
# where credit is due ...
|
||||||
|
LABEL maintainer="Sebastián Ramirez <tiangolo@gmail.com>"
|
||||||
|
WORKDIR /usr/local/share/uvicorn-gunicorn
|
||||||
|
|
||||||
|
# install uvicorn-gunicorn
|
||||||
|
COPY ./scripts/mini-tiangolo ./
|
||||||
|
|
||||||
|
RUN set -ex; \
|
||||||
|
chmod +x start.sh; \
|
||||||
|
python3 -m pip --no-cache-dir install gunicorn;
|
||||||
|
|
||||||
|
CMD ["/usr/local/share/uvicorn-gunicorn/start.sh"]
|
||||||
|
|
||||||
|
###########
|
||||||
|
# web app #
|
||||||
|
###########
|
||||||
|
|
||||||
|
FROM uvicorn-gunicorn AS production
|
||||||
|
|
||||||
|
# env setup
|
||||||
|
ENV \
|
||||||
|
PRODUCTION_MODE="true" \
|
||||||
|
PORT="8000" \
|
||||||
|
MODULE_NAME="advent22_api.app"
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
WORKDIR /opt/advent22
|
||||||
|
VOLUME [ "/opt/advent22" ]
|
||||||
|
|
||||||
|
COPY --from=build-api /usr/local/src/advent22_api/dist /usr/local/share/advent22_api.dist
|
||||||
|
RUN set -ex; \
|
||||||
|
# remove example app
|
||||||
|
rm -rf /app; \
|
||||||
|
\
|
||||||
|
# # runtime dependencies
|
||||||
|
# apt-get update; apt-get install --yes --no-install-recommends \
|
||||||
|
# ; rm -rf /var/lib/apt/lists/*; \
|
||||||
|
\
|
||||||
|
# install advent22_api wheels
|
||||||
|
python3 -m pip --no-cache-dir install --no-deps /usr/local/share/advent22_api.dist/*.whl; \
|
||||||
|
\
|
||||||
|
# prepare data directory
|
||||||
|
chown nobody:nogroup ./
|
||||||
|
|
||||||
|
# add prepared advent22_ui
|
||||||
|
COPY --from=build-ui /tmp/advent22_ui /usr/local/share/advent22_ui
|
||||||
|
|
||||||
|
# run as unprivileged user
|
||||||
|
USER nobody
|
||||||
23
Ideen.md
Normal file
23
Ideen.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# MUSS
|
||||||
|
|
||||||
|
|
||||||
|
# KANN
|
||||||
|
|
||||||
|
- api/ui: Türchen mit Tag "0" einem zufälligen Tag zuweisen
|
||||||
|
- api/?: Option "custom Zuordnung Buchstaben" (standard leer)
|
||||||
|
- ui: `confirm` durch bulma Komponente(n) ersetzen
|
||||||
|
- halbautomatischer Modus: Finde Bilder wie "a.jpg" und "Z.png" und weise diese den passenden Tagen zu
|
||||||
|
|
||||||
|
# Erledigt
|
||||||
|
|
||||||
|
- Türchen anzeigen im DoorMapEditor
|
||||||
|
- Lösungsbuchstaben weniger als türchen erzeugt bug
|
||||||
|
- Türchen sichtbar machen (besser für touch, standard nein)
|
||||||
|
- Option "Nur Groß-/Kleinbuchstaben" (standard nur groß)
|
||||||
|
- Option "Leerzeichen ignorieren" (standard ja)
|
||||||
|
- Nach einigen Sekunden: Meldung "Türchen anzeigen?"
|
||||||
|
- `alert` durch bulma Komponente(n) ersetzen
|
||||||
|
- api: admin Login case sensitivity (username "admin" == "AdMiN")
|
||||||
|
- api: `config.solution` - whitespace="IGNORE"->"REMOVE" umbenennen, +Sonderzeichen
|
||||||
|
- api: Config-Option "Überspringe leere Türchen" (standard ja)
|
||||||
|
- api: Config-Liste von Extra-Türchen (kein Buchstabe, nur manuelles Bild)
|
||||||
56
api/.devcontainer/devcontainer.json
Normal file
56
api/.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
|
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||||
|
{
|
||||||
|
"name": "Advent22 API",
|
||||||
|
|
||||||
|
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/python:3-3.14-trixie",
|
||||||
|
|
||||||
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/git-lfs:1": {},
|
||||||
|
"ghcr.io/devcontainers-extra/features/poetry:2": {},
|
||||||
|
"ghcr.io/devcontainers-extra/features/apt-get-packages:1": {
|
||||||
|
"packages": "git-flow"
|
||||||
|
},
|
||||||
|
"ghcr.io/itsmechlark/features/redis-server:1": {}
|
||||||
|
},
|
||||||
|
|
||||||
|
"containerEnv": {
|
||||||
|
"TZ": "Europe/Berlin"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
"customizations": {
|
||||||
|
// Configure properties specific to VS Code.
|
||||||
|
"vscode": {
|
||||||
|
// Set *default* container specific settings.json values on container create.
|
||||||
|
"settings": {
|
||||||
|
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||||
|
"terminal.integrated.defaultProfile.linux": "zsh"
|
||||||
|
},
|
||||||
|
// Add the IDs of extensions you want installed when the container is created.
|
||||||
|
"extensions": [
|
||||||
|
"be5invis.toml",
|
||||||
|
"mhutchie.git-graph",
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-python.black-formatter",
|
||||||
|
"ms-python.flake8",
|
||||||
|
"ms-python.isort",
|
||||||
|
"ms-python.vscode-pylance"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
"postCreateCommand": "sudo /usr/local/py-utils/bin/poetry self add poetry-plugin-up",
|
||||||
|
|
||||||
|
// Use 'postStartCommand' to run commands after the container is started.
|
||||||
|
"postStartCommand": "poetry install"
|
||||||
|
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
// "forwardPorts": [],
|
||||||
|
|
||||||
|
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
// "remoteUser": "root"
|
||||||
|
}
|
||||||
4
api/.flake8
Normal file
4
api/.flake8
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[flake8]
|
||||||
|
max-line-length = 80
|
||||||
|
extend-select = B950
|
||||||
|
extend-ignore = E203,E501
|
||||||
1
.gitignore → api/.gitignore
vendored
1
.gitignore → api/.gitignore
vendored
|
|
@ -152,3 +152,4 @@ cython_debug/
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
|
api.conf
|
||||||
3
api/.isort.cfg
Normal file
3
api/.isort.cfg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[settings]
|
||||||
|
profile = black
|
||||||
|
line_length = 80
|
||||||
22
api/.vscode/launch.json
vendored
Normal file
22
api/.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
// Verwendet IntelliSense zum Ermitteln möglicher Attribute.
|
||||||
|
// Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen.
|
||||||
|
// Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Main Module",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "advent22_api.main",
|
||||||
|
"pythonArgs": [
|
||||||
|
"-Xfrozen_modules=off",
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
|
||||||
|
"WEBDAV__CACHE_TTL": "30",
|
||||||
|
},
|
||||||
|
"justMyCode": true,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
33
api/.vscode/settings.json
vendored
Normal file
33
api/.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"git.closeDiffOnOperation": true,
|
||||||
|
|
||||||
|
"[python]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "ms-python.black-formatter",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports": "explicit",
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"python.languageServer": "Pylance",
|
||||||
|
"python.analysis.autoImportCompletions": true,
|
||||||
|
"python.analysis.importFormat": "relative",
|
||||||
|
"python.analysis.fixAll": [
|
||||||
|
"source.convertImportFormat",
|
||||||
|
"source.unusedImports",
|
||||||
|
],
|
||||||
|
"python.analysis.typeCheckingMode": "basic",
|
||||||
|
"python.analysis.diagnosticMode": "workspace",
|
||||||
|
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"--import-mode=importlib",
|
||||||
|
"test",
|
||||||
|
],
|
||||||
|
|
||||||
|
"black-formatter.importStrategy": "fromEnvironment",
|
||||||
|
"flake8.importStrategy": "fromEnvironment",
|
||||||
|
"isort.importStrategy": "fromEnvironment",
|
||||||
|
}
|
||||||
46
api/advent22_api/app.py
Normal file
46
api/advent22_api/app.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .core.settings import SETTINGS
|
||||||
|
from .routers import router
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Advent22 API",
|
||||||
|
description="This API enables the `Advent22` service.",
|
||||||
|
contact={
|
||||||
|
"name": "Jörn-Michael Miehe",
|
||||||
|
"email": "jmm@yavook.de",
|
||||||
|
},
|
||||||
|
license_info={
|
||||||
|
"name": "MIT License",
|
||||||
|
"url": "https://opensource.org/licenses/mit-license.php",
|
||||||
|
},
|
||||||
|
openapi_url=SETTINGS.openapi_url,
|
||||||
|
docs_url=SETTINGS.docs_url,
|
||||||
|
redoc_url=SETTINGS.redoc_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
if SETTINGS.production_mode:
|
||||||
|
# Mount frontend in production mode
|
||||||
|
app.mount(
|
||||||
|
path="/",
|
||||||
|
app=StaticFiles(
|
||||||
|
directory=SETTINGS.ui_directory,
|
||||||
|
html=True,
|
||||||
|
),
|
||||||
|
name="frontend",
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Allow CORS in debug mode
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
expose_headers=["*"],
|
||||||
|
)
|
||||||
0
api/advent22_api/core/__init__.py
Normal file
0
api/advent22_api/core/__init__.py
Normal file
148
api/advent22_api/core/advent_image.py
Normal file
148
api/advent22_api/core/advent_image.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import colorsys
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import AnyStr, Self, TypeAlias
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
from PIL import ImageDraw
|
||||||
|
from PIL.Image import Image, Resampling
|
||||||
|
from PIL.ImageFont import FreeTypeFont
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
_RGB: TypeAlias = tuple[int, int, int]
|
||||||
|
_XY: TypeAlias = tuple[float, float]
|
||||||
|
_Box: TypeAlias = tuple[int, int, int, int]
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class AdventImage:
|
||||||
|
img: Image
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def from_img(cls, img: Image, cfg: Config) -> Self:
|
||||||
|
"""
|
||||||
|
Einen quadratischen Ausschnitt aus der Mitte des Bilds nehmen
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Farbmodell festlegen
|
||||||
|
img = img.convert(mode="RGB")
|
||||||
|
|
||||||
|
# Größen bestimmen
|
||||||
|
width, height = img.size
|
||||||
|
square = min(width, height)
|
||||||
|
|
||||||
|
# zuschneiden
|
||||||
|
img = img.crop(
|
||||||
|
box=(
|
||||||
|
int((width - square) / 2),
|
||||||
|
int((height - square) / 2),
|
||||||
|
int((width + square) / 2),
|
||||||
|
int((height + square) / 2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# skalieren
|
||||||
|
return cls(
|
||||||
|
img.resize(
|
||||||
|
size=(cfg.image.size, cfg.image.size),
|
||||||
|
resample=Resampling.LANCZOS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_text_box(
|
||||||
|
self,
|
||||||
|
xy: _XY,
|
||||||
|
text: AnyStr,
|
||||||
|
font: FreeTypeFont,
|
||||||
|
anchor: str | None = "mm",
|
||||||
|
**text_kwargs,
|
||||||
|
) -> _Box | None:
|
||||||
|
"""
|
||||||
|
Koordinaten (links, oben, rechts, unten) des betroffenen
|
||||||
|
Rechtecks bestimmen, wenn das Bild mit einem Text
|
||||||
|
versehen wird
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Neues 1-Bit Bild, gleiche Größe
|
||||||
|
mask = PILImage.new(mode="1", size=self.img.size)
|
||||||
|
|
||||||
|
# Text auf Maske auftragen
|
||||||
|
ImageDraw.Draw(mask).text(
|
||||||
|
xy=xy,
|
||||||
|
text=text,
|
||||||
|
font=font,
|
||||||
|
anchor=anchor,
|
||||||
|
fill=1,
|
||||||
|
**text_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# betroffenen Pixelbereich bestimmen
|
||||||
|
return mask.getbbox()
|
||||||
|
|
||||||
|
async def get_average_color(
|
||||||
|
self,
|
||||||
|
box: _Box,
|
||||||
|
) -> _RGB:
|
||||||
|
"""
|
||||||
|
Durchschnittsfarbe eines rechteckigen Ausschnitts in
|
||||||
|
einem Bild berechnen
|
||||||
|
"""
|
||||||
|
|
||||||
|
pixel_data = np.asarray(self.img.crop(box))
|
||||||
|
mean_color: np.ndarray = np.mean(pixel_data, axis=(0, 1))
|
||||||
|
|
||||||
|
return _RGB(mean_color.astype(int))
|
||||||
|
|
||||||
|
async def hide_text(
|
||||||
|
self,
|
||||||
|
xy: _XY,
|
||||||
|
text: AnyStr,
|
||||||
|
font: FreeTypeFont,
|
||||||
|
anchor: str | None = "mm",
|
||||||
|
**text_kwargs,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Text `text` in Bild an Position `xy` verstecken.
|
||||||
|
Weitere Parameter wie bei `ImageDraw.text()`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# betroffenen Bildbereich bestimmen
|
||||||
|
text_box = await self.get_text_box(
|
||||||
|
xy=xy, text=text, font=font, anchor=anchor, **text_kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
if text_box is None:
|
||||||
|
_logger.warning("Konnte Bildbereich nicht finden!")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Durchschnittsfarbe bestimmen
|
||||||
|
text_color = await self.get_average_color(
|
||||||
|
box=text_box,
|
||||||
|
)
|
||||||
|
|
||||||
|
# etwas heller/dunkler machen
|
||||||
|
tc_h, tc_s, tc_v = colorsys.rgb_to_hsv(*text_color)
|
||||||
|
tc_v = int((tc_v - 127) * 0.97) + 127
|
||||||
|
|
||||||
|
if tc_v < 127:
|
||||||
|
tc_v += 3
|
||||||
|
|
||||||
|
else:
|
||||||
|
tc_v -= 3
|
||||||
|
|
||||||
|
text_color = colorsys.hsv_to_rgb(tc_h, tc_s, tc_v)
|
||||||
|
text_color = _RGB(int(val) for val in text_color)
|
||||||
|
|
||||||
|
# Buchstaben verstecken
|
||||||
|
ImageDraw.Draw(self.img).text(
|
||||||
|
xy=xy,
|
||||||
|
text=text,
|
||||||
|
font=font,
|
||||||
|
fill=text_color,
|
||||||
|
anchor=anchor,
|
||||||
|
**text_kwargs,
|
||||||
|
)
|
||||||
55
api/advent22_api/core/calendar_config.py
Normal file
55
api/advent22_api/core/calendar_config.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import tomllib
|
||||||
|
from typing import TypeAlias
|
||||||
|
|
||||||
|
import tomli_w
|
||||||
|
from fastapi import Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .config import Config, get_config
|
||||||
|
from .dav.webdav import WebDAV
|
||||||
|
|
||||||
|
|
||||||
|
class DoorSaved(BaseModel):
|
||||||
|
# Tag, an dem die Tür aufgeht
|
||||||
|
day: int
|
||||||
|
|
||||||
|
# Koordinaten für zwei Eckpunkte
|
||||||
|
x1: int
|
||||||
|
y1: int
|
||||||
|
x2: int
|
||||||
|
y2: int
|
||||||
|
|
||||||
|
|
||||||
|
DoorsSaved: TypeAlias = list[DoorSaved]
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarConfig(BaseModel):
|
||||||
|
# Dateiname Hintergrundbild
|
||||||
|
background: str = "adventskalender.jpg"
|
||||||
|
|
||||||
|
# Dateiname Favicon
|
||||||
|
favicon: str = "favicon.png"
|
||||||
|
|
||||||
|
# Türen für die UI
|
||||||
|
doors: DoorsSaved = []
|
||||||
|
|
||||||
|
async def change(self, cfg: Config) -> None:
|
||||||
|
"""
|
||||||
|
Kalender Konfiguration ändern
|
||||||
|
"""
|
||||||
|
|
||||||
|
await WebDAV.write_str(
|
||||||
|
path=f"files/{cfg.calendar}",
|
||||||
|
content=tomli_w.dumps(self.model_dump()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_calendar_config(
|
||||||
|
cfg: Config = Depends(get_config),
|
||||||
|
) -> CalendarConfig:
|
||||||
|
"""
|
||||||
|
Kalender Konfiguration lesen
|
||||||
|
"""
|
||||||
|
|
||||||
|
txt = await WebDAV.read_str(path=f"files/{cfg.calendar}")
|
||||||
|
return CalendarConfig.model_validate(tomllib.loads(txt))
|
||||||
81
api/advent22_api/core/config.py
Normal file
81
api/advent22_api/core/config.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from markdown import markdown
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
|
|
||||||
|
from .dav.webdav import WebDAV
|
||||||
|
from .settings import SETTINGS, Credentials
|
||||||
|
from .transformed_string import TransformedString
|
||||||
|
|
||||||
|
|
||||||
|
class Site(BaseModel):
|
||||||
|
model_config = ConfigDict(validate_default=True)
|
||||||
|
|
||||||
|
# Titel
|
||||||
|
title: str
|
||||||
|
|
||||||
|
# Untertitel
|
||||||
|
subtitle: str
|
||||||
|
|
||||||
|
# Inhalt der Seite
|
||||||
|
content: str
|
||||||
|
|
||||||
|
# Fußzeile der Seite
|
||||||
|
footer: str = "**Advent22** by [Lenaisten e.V.](//www.lenaisten.de)"
|
||||||
|
|
||||||
|
@field_validator("content", "footer", mode="after")
|
||||||
|
def parse_md(cls, v) -> str:
|
||||||
|
return markdown(v)
|
||||||
|
|
||||||
|
|
||||||
|
class Puzzle(BaseModel):
|
||||||
|
# Tag, an dem der Kalender startet
|
||||||
|
begin_day: int = 1
|
||||||
|
|
||||||
|
# Monat, in dem der Kalender startet
|
||||||
|
begin_month: int = 12
|
||||||
|
|
||||||
|
# Kalender so viele Tage nach der letzten Türöffnung schließen
|
||||||
|
close_after: int = 90
|
||||||
|
|
||||||
|
# Tage, für die kein Buchstabe vorgesehen wird
|
||||||
|
extra_days: set[int] = set()
|
||||||
|
|
||||||
|
# Türchen ohne Buchstabe überspringen
|
||||||
|
skip_empty: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class Image(BaseModel):
|
||||||
|
# Quadrat, Seitenlänge in px
|
||||||
|
size: int = 1000
|
||||||
|
|
||||||
|
# Rand in px, wo keine Buchstaben untergebracht werden
|
||||||
|
border: int = 60
|
||||||
|
|
||||||
|
|
||||||
|
class Config(BaseModel):
|
||||||
|
# Login-Daten für Admin-Modus
|
||||||
|
admin: Credentials
|
||||||
|
|
||||||
|
# Lösungswort
|
||||||
|
solution: TransformedString
|
||||||
|
|
||||||
|
# Weitere Einstellungen
|
||||||
|
site: Site
|
||||||
|
puzzle: Puzzle
|
||||||
|
image: Image
|
||||||
|
|
||||||
|
# Kalenderdefinition
|
||||||
|
calendar: str = "default.toml"
|
||||||
|
|
||||||
|
# Serverseitiger zusätzlicher "random" seed
|
||||||
|
random_seed: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
async def get_config() -> Config:
|
||||||
|
"""
|
||||||
|
Globale Konfiguration lesen
|
||||||
|
"""
|
||||||
|
|
||||||
|
txt = await WebDAV.read_str(path=SETTINGS.webdav.config_filename)
|
||||||
|
return Config.model_validate(tomllib.loads(txt))
|
||||||
0
api/advent22_api/core/dav/__init__.py
Normal file
0
api/advent22_api/core/dav/__init__.py
Normal file
60
api/advent22_api/core/dav/helpers.py
Normal file
60
api/advent22_api/core/dav/helpers.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
from json import JSONDecodeError
|
||||||
|
from typing import Callable, Hashable
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from cachetools.keys import hashkey
|
||||||
|
from CacheToolsUtils import RedisCache as __RedisCache
|
||||||
|
from redis.typing import EncodableT, ResponseT
|
||||||
|
from webdav3.client import Client as __WebDAVclient
|
||||||
|
|
||||||
|
|
||||||
|
def davkey(
|
||||||
|
name: str,
|
||||||
|
slice: slice = slice(1, None),
|
||||||
|
) -> Callable[..., tuple[Hashable, ...]]:
|
||||||
|
def func(*args, **kwargs) -> tuple[Hashable, ...]:
|
||||||
|
"""Return a cache key for use with cached methods."""
|
||||||
|
|
||||||
|
key = hashkey(name, *args[slice], **kwargs)
|
||||||
|
return hashkey(*(str(key_item) for key_item in key))
|
||||||
|
|
||||||
|
return func
|
||||||
|
|
||||||
|
|
||||||
|
class WebDAVclient(__WebDAVclient):
|
||||||
|
def execute_request(
|
||||||
|
self,
|
||||||
|
action,
|
||||||
|
path,
|
||||||
|
data=None,
|
||||||
|
headers_ext=None,
|
||||||
|
) -> requests.Response:
|
||||||
|
res = super().execute_request(action, path, data, headers_ext)
|
||||||
|
|
||||||
|
# the "Content-Length" header can randomly be missing on txt files,
|
||||||
|
# this should fix that (probably serverside bug)
|
||||||
|
if action == "download" and "Content-Length" not in res.headers:
|
||||||
|
res.headers["Content-Length"] = str(len(res.text))
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
class RedisCache(__RedisCache):
|
||||||
|
"""
|
||||||
|
Redis handles <bytes>, so ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _serialize(self, s) -> EncodableT:
|
||||||
|
if isinstance(s, bytes):
|
||||||
|
return s
|
||||||
|
|
||||||
|
else:
|
||||||
|
return super()._serialize(s)
|
||||||
|
|
||||||
|
def _deserialize(self, s: ResponseT):
|
||||||
|
try:
|
||||||
|
return super()._deserialize(s)
|
||||||
|
|
||||||
|
except (UnicodeDecodeError, JSONDecodeError):
|
||||||
|
assert isinstance(s, bytes)
|
||||||
|
return s
|
||||||
108
api/advent22_api/core/dav/webdav.py
Normal file
108
api/advent22_api/core/dav/webdav.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from asyncify import asyncify
|
||||||
|
from cachetools import cachedmethod
|
||||||
|
from redis import Redis
|
||||||
|
|
||||||
|
from ..settings import SETTINGS
|
||||||
|
from .helpers import RedisCache, WebDAVclient, davkey
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebDAV:
|
||||||
|
_webdav_client = WebDAVclient(
|
||||||
|
{
|
||||||
|
"webdav_hostname": SETTINGS.webdav.url,
|
||||||
|
"webdav_login": SETTINGS.webdav.auth.username,
|
||||||
|
"webdav_password": SETTINGS.webdav.auth.password,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_cache = RedisCache(
|
||||||
|
cache=Redis(
|
||||||
|
host=SETTINGS.redis.host,
|
||||||
|
port=SETTINGS.redis.port,
|
||||||
|
db=SETTINGS.redis.db,
|
||||||
|
protocol=SETTINGS.redis.protocol,
|
||||||
|
),
|
||||||
|
ttl=SETTINGS.webdav.cache_ttl,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@asyncify
|
||||||
|
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("list_files"))
|
||||||
|
def list_files(
|
||||||
|
cls,
|
||||||
|
directory: str = "",
|
||||||
|
*,
|
||||||
|
regex: re.Pattern[str] = re.compile(""),
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
List files in directory `directory` matching RegEx `regex`
|
||||||
|
"""
|
||||||
|
|
||||||
|
_logger.debug(f"list_files {directory!r}")
|
||||||
|
ls = cls._webdav_client.list(directory)
|
||||||
|
|
||||||
|
return [path for path in ls if regex.search(path)]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@asyncify
|
||||||
|
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("exists"))
|
||||||
|
def exists(cls, path: str) -> bool:
|
||||||
|
"""
|
||||||
|
`True` iff there is a WebDAV resource at `path`
|
||||||
|
"""
|
||||||
|
|
||||||
|
_logger.debug(f"file_exists {path!r}")
|
||||||
|
return cls._webdav_client.check(path)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@asyncify
|
||||||
|
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("read_bytes"))
|
||||||
|
def read_bytes(cls, path: str) -> bytes:
|
||||||
|
"""
|
||||||
|
Load WebDAV file from `path` as bytes
|
||||||
|
"""
|
||||||
|
|
||||||
|
_logger.debug(f"read_bytes {path!r}")
|
||||||
|
buffer = BytesIO()
|
||||||
|
cls._webdav_client.download_from(buffer, path)
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
return buffer.read()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def read_str(cls, path: str, encoding="utf-8") -> str:
|
||||||
|
"""
|
||||||
|
Load WebDAV file from `path` as string
|
||||||
|
"""
|
||||||
|
|
||||||
|
_logger.debug(f"read_str {path!r}")
|
||||||
|
return (await cls.read_bytes(path)).decode(encoding=encoding).strip()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@asyncify
|
||||||
|
def write_bytes(cls, path: str, buffer: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Write bytes from `buffer` into WebDAV file at `path`
|
||||||
|
"""
|
||||||
|
|
||||||
|
_logger.debug(f"write_bytes {path!r}")
|
||||||
|
cls._webdav_client.upload_to(buffer, path)
|
||||||
|
|
||||||
|
# invalidate cache entry
|
||||||
|
# explicit slice as there is no "cls" argument
|
||||||
|
del cls._cache[davkey("read_bytes", slice(0, None))(path)]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def write_str(cls, path: str, content: str, encoding="utf-8") -> None:
|
||||||
|
"""
|
||||||
|
Write string from `content` into WebDAV file at `path`
|
||||||
|
"""
|
||||||
|
|
||||||
|
_logger.debug(f"write_str {path!r}")
|
||||||
|
await cls.write_bytes(path, content.encode(encoding=encoding))
|
||||||
230
api/advent22_api/core/depends.py
Normal file
230
api/advent22_api/core/depends.py
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from PIL import ImageFont
|
||||||
|
from PIL.Image import Image
|
||||||
|
from PIL.ImageFont import FreeTypeFont
|
||||||
|
|
||||||
|
from .advent_image import _XY, AdventImage
|
||||||
|
from .calendar_config import CalendarConfig, get_calendar_config
|
||||||
|
from .config import Config, get_config
|
||||||
|
from .dav.webdav import WebDAV
|
||||||
|
from .helpers import (
|
||||||
|
RE_TTF,
|
||||||
|
EventDates,
|
||||||
|
Random,
|
||||||
|
list_fonts,
|
||||||
|
list_images_auto,
|
||||||
|
list_images_manual,
|
||||||
|
load_image,
|
||||||
|
set_len,
|
||||||
|
)
|
||||||
|
|
||||||
|
RE_NUM = re.compile(r"/(\d+)\.", flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_sorted_days(
|
||||||
|
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||||
|
) -> list[int]:
|
||||||
|
"""
|
||||||
|
Alle Tage, für die es ein Türchen gibt
|
||||||
|
"""
|
||||||
|
|
||||||
|
return sorted(set(door.day for door in cal_cfg.doors))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_parts(
|
||||||
|
cfg: Config = Depends(get_config),
|
||||||
|
days: list[int] = Depends(get_all_sorted_days),
|
||||||
|
) -> dict[int, str]:
|
||||||
|
"""
|
||||||
|
Lösung auf vorhandene Tage aufteilen
|
||||||
|
"""
|
||||||
|
|
||||||
|
# noch keine Buchstaben verteilt
|
||||||
|
result = {day: "" for day in days}
|
||||||
|
# extra-Tage ausfiltern
|
||||||
|
days = [day for day in days if day not in cfg.puzzle.extra_days]
|
||||||
|
|
||||||
|
solution_length = len(cfg.solution.clean)
|
||||||
|
num_days = len(days)
|
||||||
|
|
||||||
|
rnd = await Random.get()
|
||||||
|
solution_days = [
|
||||||
|
# wie oft passen die Tage "ganz" in die Länge der Lösung?
|
||||||
|
# zB 26 Buchstaben // 10 Tage == 2 mal => 2 Zeichen pro Tag
|
||||||
|
*rnd.shuffled(days * (solution_length // num_days)),
|
||||||
|
# wie viele Buchstaben bleiben übrig?
|
||||||
|
# zB 26 % 10 == 6 Buchstaben => an 6 Tagen ein Zeichen mehr
|
||||||
|
*rnd.sample(days, solution_length % num_days),
|
||||||
|
]
|
||||||
|
|
||||||
|
for day, letter in zip(solution_days, cfg.solution.clean):
|
||||||
|
result[day] += letter
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_event_dates(
|
||||||
|
cfg: Config = Depends(get_config),
|
||||||
|
days: list[int] = Depends(get_all_sorted_days),
|
||||||
|
parts: dict[int, str] = Depends(get_all_parts),
|
||||||
|
) -> EventDates:
|
||||||
|
"""
|
||||||
|
Aktueller Kalender-Zeitraum
|
||||||
|
"""
|
||||||
|
|
||||||
|
if cfg.puzzle.skip_empty:
|
||||||
|
days = [day for day in days if parts[day] != "" or day in cfg.puzzle.extra_days]
|
||||||
|
|
||||||
|
return EventDates(
|
||||||
|
today=date.today(),
|
||||||
|
begin_month=cfg.puzzle.begin_month,
|
||||||
|
begin_day=cfg.puzzle.begin_day,
|
||||||
|
events=days,
|
||||||
|
close_after=cfg.puzzle.close_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_auto_image_names(
|
||||||
|
days: list[int] = Depends(get_all_sorted_days),
|
||||||
|
images: list[str] = Depends(list_images_auto),
|
||||||
|
) -> dict[int, str]:
|
||||||
|
"""
|
||||||
|
Bilder: Reihenfolge zufällig bestimmen
|
||||||
|
"""
|
||||||
|
|
||||||
|
rnd = await Random.get()
|
||||||
|
ls = set_len(images, len(days))
|
||||||
|
|
||||||
|
return dict(zip(days, rnd.shuffled(ls)))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_manual_image_names(
|
||||||
|
manual_image_names: list[str] = Depends(list_images_manual),
|
||||||
|
) -> dict[int, str]:
|
||||||
|
"""
|
||||||
|
Bilder: "manual" zuordnen
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
int(num_match.group(1)): name
|
||||||
|
for name in manual_image_names
|
||||||
|
if (num_match := RE_NUM.search(name)) is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_image_names(
|
||||||
|
auto_image_names: dict[int, str] = Depends(get_all_auto_image_names),
|
||||||
|
manual_image_names: dict[int, str] = Depends(get_all_manual_image_names),
|
||||||
|
) -> dict[int, str]:
|
||||||
|
"""
|
||||||
|
Bilder "auto" und "manual" zu Tagen zuordnen
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = auto_image_names.copy()
|
||||||
|
result.update(manual_image_names)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class TTFont:
|
||||||
|
# Dateiname
|
||||||
|
file_name: str
|
||||||
|
|
||||||
|
# Schriftgröße für den Font
|
||||||
|
size: int = 50
|
||||||
|
|
||||||
|
@property
|
||||||
|
async def font(self) -> FreeTypeFont:
|
||||||
|
return ImageFont.truetype(
|
||||||
|
font=BytesIO(await WebDAV.read_bytes(self.file_name)),
|
||||||
|
size=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_ttfonts(
|
||||||
|
font_names: list[str] = Depends(list_fonts),
|
||||||
|
) -> list[TTFont]:
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for name in font_names:
|
||||||
|
assert (size_match := RE_TTF.search(name)) is not None
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
TTFont(
|
||||||
|
file_name=name,
|
||||||
|
size=int(size_match.group(1)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def gen_day_auto_image(
|
||||||
|
day: int,
|
||||||
|
cfg: Config,
|
||||||
|
auto_image_names: dict[int, str],
|
||||||
|
day_parts: dict[int, str],
|
||||||
|
ttfonts: list[TTFont],
|
||||||
|
) -> Image:
|
||||||
|
"""
|
||||||
|
Automatisch generiertes Bild erstellen
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Datei existiert garantiert!
|
||||||
|
img = await load_image(auto_image_names[day])
|
||||||
|
image = await AdventImage.from_img(img, cfg)
|
||||||
|
|
||||||
|
rnd = await Random.get(day)
|
||||||
|
xy_range = range(cfg.image.border, (cfg.image.size - cfg.image.border))
|
||||||
|
|
||||||
|
# Buchstaben verstecken
|
||||||
|
for letter in day_parts[day]:
|
||||||
|
await image.hide_text(
|
||||||
|
xy=cast(_XY, tuple(rnd.choices(xy_range, k=2))),
|
||||||
|
text=letter,
|
||||||
|
font=await rnd.choice(ttfonts).font,
|
||||||
|
)
|
||||||
|
|
||||||
|
return image.img
|
||||||
|
|
||||||
|
|
||||||
|
async def get_day_image(
|
||||||
|
day: int,
|
||||||
|
days: list[int] = Depends(get_all_sorted_days),
|
||||||
|
cfg: Config = Depends(get_config),
|
||||||
|
manual_image_names: dict[int, str] = Depends(get_all_manual_image_names),
|
||||||
|
auto_image_names: dict[int, str] = Depends(get_all_auto_image_names),
|
||||||
|
day_parts: dict[int, str] = Depends(get_all_parts),
|
||||||
|
ttfonts: list[TTFont] = Depends(get_all_ttfonts),
|
||||||
|
) -> Image | None:
|
||||||
|
"""
|
||||||
|
Bild für einen Tag abrufen
|
||||||
|
"""
|
||||||
|
|
||||||
|
if day not in days:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Versuche "manual"-Bild zu laden
|
||||||
|
img = await load_image(manual_image_names[day])
|
||||||
|
|
||||||
|
# Als AdventImage verarbeiten
|
||||||
|
image = await AdventImage.from_img(img, cfg)
|
||||||
|
return image.img
|
||||||
|
|
||||||
|
except (KeyError, RuntimeError):
|
||||||
|
# Erstelle automatisch generiertes Bild
|
||||||
|
return await gen_day_auto_image(
|
||||||
|
day=day,
|
||||||
|
cfg=cfg,
|
||||||
|
auto_image_names=auto_image_names,
|
||||||
|
day_parts=day_parts,
|
||||||
|
ttfonts=ttfonts,
|
||||||
|
)
|
||||||
245
api/advent22_api/core/helpers.py
Normal file
245
api/advent22_api/core/helpers.py
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import base64
|
||||||
|
import itertools
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any, Awaitable, Callable, Iterable, Self, Sequence, TypeVar
|
||||||
|
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
from PIL.Image import Image, Resampling
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .config import get_config
|
||||||
|
from .dav.webdav import WebDAV
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
RE_IMG = re.compile(r"\.(gif|jpe?g|tiff?|png|bmp)$", flags=re.IGNORECASE)
|
||||||
|
RE_TTF = re.compile(r"_(\d+)\.ttf$", flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
class Random(random.Random):
|
||||||
|
@classmethod
|
||||||
|
async def get(cls, bonus_salt: Any = "") -> Self:
|
||||||
|
cfg = await get_config()
|
||||||
|
return cls(f"{cfg.solution.clean}{cfg.random_seed}{bonus_salt}")
|
||||||
|
|
||||||
|
def shuffled(self, population: Sequence[T]) -> Sequence[T]:
|
||||||
|
return self.sample(population, k=len(population))
|
||||||
|
|
||||||
|
|
||||||
|
def set_len(seq: Sequence[T], len: int) -> Sequence[T]:
|
||||||
|
# `seq` unendlich wiederholen
|
||||||
|
infinite = itertools.cycle(seq)
|
||||||
|
|
||||||
|
# Die ersten `length` einträge nehmen
|
||||||
|
return list(itertools.islice(infinite, len))
|
||||||
|
|
||||||
|
|
||||||
|
def spread(
|
||||||
|
given: Iterable[int],
|
||||||
|
n: int,
|
||||||
|
rnd: Random | None = None,
|
||||||
|
) -> list[int]:
|
||||||
|
"""
|
||||||
|
Zu `given` ganzen Zahlen `n` zusätzliche Zahlen hinzunehmen.
|
||||||
|
|
||||||
|
- Die neuen Werte sind im selben Zahlenbereich wie `given`
|
||||||
|
- Zuerst werden alle Werte "zwischen" den `given` Werten genommen
|
||||||
|
"""
|
||||||
|
|
||||||
|
if n == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if len(set(given)) > 1:
|
||||||
|
range_given = range(min(given), max(given) + 1)
|
||||||
|
first_round = set(range_given) - set(given)
|
||||||
|
|
||||||
|
elif len(set(given)) == 1:
|
||||||
|
if (a := next(iter(given))) > 0:
|
||||||
|
range_given = range(1, a + 1)
|
||||||
|
else:
|
||||||
|
range_given = range(1, n + 1)
|
||||||
|
|
||||||
|
first_round = set(range_given) - set(given)
|
||||||
|
|
||||||
|
else:
|
||||||
|
range_given = range(1, n + 1)
|
||||||
|
first_round = range_given
|
||||||
|
|
||||||
|
result = sorted(first_round)[: min(n, len(first_round))]
|
||||||
|
|
||||||
|
full_rounds = (n - len(result)) // len(range_given)
|
||||||
|
result += list(range_given) * full_rounds
|
||||||
|
|
||||||
|
remain = n - len(result)
|
||||||
|
if rnd is None:
|
||||||
|
result += list(range_given)[:remain]
|
||||||
|
|
||||||
|
else:
|
||||||
|
result += rnd.sample(range_given, remain)
|
||||||
|
rnd.shuffle(result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def list_helper(
|
||||||
|
directory: str,
|
||||||
|
regex: re.Pattern[str],
|
||||||
|
) -> Callable[[], Awaitable[list[str]]]:
|
||||||
|
"""
|
||||||
|
Finde alle Dateien im Verzeichnis `dir`, passend zu `re`
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _list_helper() -> list[str]:
|
||||||
|
return [
|
||||||
|
f"{directory}/{file}"
|
||||||
|
for file in await WebDAV.list_files(directory=directory, regex=regex)
|
||||||
|
]
|
||||||
|
|
||||||
|
return _list_helper
|
||||||
|
|
||||||
|
|
||||||
|
list_images_auto = list_helper("/images_auto", RE_IMG)
|
||||||
|
list_images_manual = list_helper("/images_manual", RE_IMG)
|
||||||
|
list_fonts = list_helper("/files", RE_TTF)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_image(file_name: str) -> Image:
|
||||||
|
"""
|
||||||
|
Versuche, Bild aus Datei zu laden
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not await WebDAV.exists(file_name):
|
||||||
|
raise RuntimeError(f"DAV-File {file_name} does not exist!")
|
||||||
|
|
||||||
|
return PILImage.open(BytesIO(await WebDAV.read_bytes(file_name)))
|
||||||
|
|
||||||
|
|
||||||
|
class ImageData(BaseModel):
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
aspect_ratio: float
|
||||||
|
data_url: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
media_type: str,
|
||||||
|
content: BytesIO,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
) -> Self:
|
||||||
|
img_data = base64.b64encode(content.getvalue()).decode("utf-8")
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
aspect_ratio=width / height,
|
||||||
|
data_url=f"data:{media_type};base64,{img_data}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_return_ico(img: Image) -> ImageData:
|
||||||
|
"""
|
||||||
|
ICO-Bild mit API zurückgeben
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ICO-Daten in Puffer speichern (256px)
|
||||||
|
img_buffer = BytesIO()
|
||||||
|
img.resize(size=(256, 256), resample=Resampling.LANCZOS)
|
||||||
|
img.save(img_buffer, format="ICO")
|
||||||
|
|
||||||
|
# zurückgeben
|
||||||
|
return ImageData.create(
|
||||||
|
media_type="image/x-icon",
|
||||||
|
content=img_buffer,
|
||||||
|
width=img.width,
|
||||||
|
height=img.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_return_jpeg(img: Image) -> ImageData:
|
||||||
|
"""
|
||||||
|
JPEG-Bild mit API zurückgeben
|
||||||
|
"""
|
||||||
|
|
||||||
|
# JPEG-Daten in Puffer speichern
|
||||||
|
img_buffer = BytesIO()
|
||||||
|
img.save(img_buffer, format="JPEG", quality=85)
|
||||||
|
|
||||||
|
# zurückgeben
|
||||||
|
return ImageData.create(
|
||||||
|
media_type="image/jpeg",
|
||||||
|
content=img_buffer,
|
||||||
|
width=img.width,
|
||||||
|
height=img.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EventDates:
|
||||||
|
"""
|
||||||
|
Events in einem Ereigniszeitraum
|
||||||
|
"""
|
||||||
|
|
||||||
|
__overall_duration: timedelta
|
||||||
|
dates: dict[int, date]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def first(self) -> date:
|
||||||
|
"""Datum des ersten Ereignisses"""
|
||||||
|
return self.dates[min(self.dates.keys())]
|
||||||
|
|
||||||
|
def get_next(self, *, today: date) -> date | None:
|
||||||
|
"""Datum des nächsten Ereignisses"""
|
||||||
|
return next(
|
||||||
|
(event for event in sorted(self.dates.values()) if event > today), None
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def next(self) -> date | None:
|
||||||
|
"""Datum des nächsten Ereignisses"""
|
||||||
|
return self.get_next(today=date.today())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last(self) -> date:
|
||||||
|
"""Datum des letzten Ereignisses"""
|
||||||
|
return self.dates[max(self.dates.keys())]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def end(self) -> date:
|
||||||
|
"""Letztes Datum des Ereigniszeitraums"""
|
||||||
|
return self.first + self.__overall_duration
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
# current date
|
||||||
|
today: date,
|
||||||
|
# month/day when events begin
|
||||||
|
begin_month: int,
|
||||||
|
begin_day: int,
|
||||||
|
# events: e.g. a 2 means there is an event on the 2nd day
|
||||||
|
# i.e. 1 day after begin
|
||||||
|
# - assume sorted (ascending)
|
||||||
|
events: list[int],
|
||||||
|
# countdown to closing begins after last event
|
||||||
|
close_after: int,
|
||||||
|
) -> None:
|
||||||
|
# account for the last event, then add closing period
|
||||||
|
self.__overall_duration = timedelta(days=events[-1] - 1 + close_after)
|
||||||
|
|
||||||
|
# the events may begin last year, this year or next year
|
||||||
|
maybe_begin = (
|
||||||
|
datetime(today.year + year_diff, begin_month, begin_day).date()
|
||||||
|
for year_diff in (-1, 0, +1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# find the first begin where the end date is in the future
|
||||||
|
begin = next(
|
||||||
|
begin for begin in maybe_begin if today <= (begin + self.__overall_duration)
|
||||||
|
)
|
||||||
|
|
||||||
|
# all event dates
|
||||||
|
self.dates = {event: begin + timedelta(days=event - 1) for event in events}
|
||||||
100
api/advent22_api/core/settings.py
Normal file
100
api/advent22_api/core/settings.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class Credentials(BaseModel):
|
||||||
|
username: str = ""
|
||||||
|
password: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DavSettings(BaseModel):
|
||||||
|
"""
|
||||||
|
Connection to a DAV server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
protocol: str = "https"
|
||||||
|
host: str = "example.com"
|
||||||
|
path: str = "/remote.php/webdav"
|
||||||
|
prefix: str = "/advent22"
|
||||||
|
|
||||||
|
auth: Credentials = Credentials(
|
||||||
|
username="advent22_user",
|
||||||
|
password="password",
|
||||||
|
)
|
||||||
|
|
||||||
|
cache_ttl: int = 60 * 10
|
||||||
|
config_filename: str = "config.toml"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
"""
|
||||||
|
Combined DAV URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"{self.protocol}://{self.host}{self.path}{self.prefix}"
|
||||||
|
|
||||||
|
|
||||||
|
class RedisSettings(BaseModel):
|
||||||
|
"""
|
||||||
|
Connection to a redis server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
host: str = "localhost"
|
||||||
|
port: int = 6379
|
||||||
|
db: int = 0
|
||||||
|
protocol: int = 3
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""
|
||||||
|
Per-run settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file="api.conf",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
)
|
||||||
|
|
||||||
|
#####
|
||||||
|
# general settings
|
||||||
|
#####
|
||||||
|
|
||||||
|
production_mode: bool = False
|
||||||
|
ui_directory: str = "/usr/local/share/advent22_ui/html"
|
||||||
|
|
||||||
|
#####
|
||||||
|
# openapi settings
|
||||||
|
#####
|
||||||
|
|
||||||
|
def __dev_value(self, value: T) -> T | None:
|
||||||
|
if self.production_mode:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def openapi_url(self) -> str | None:
|
||||||
|
return self.__dev_value("/api/openapi.json")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def docs_url(self) -> str | None:
|
||||||
|
return self.__dev_value("/api/docs")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redoc_url(self) -> str | None:
|
||||||
|
return self.__dev_value("/api/redoc")
|
||||||
|
|
||||||
|
#####
|
||||||
|
# webdav settings
|
||||||
|
#####
|
||||||
|
|
||||||
|
webdav: DavSettings = DavSettings()
|
||||||
|
redis: RedisSettings = RedisSettings()
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS = Settings()
|
||||||
96
api/advent22_api/core/transformed_string.py
Normal file
96
api/advent22_api/core/transformed_string.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import re
|
||||||
|
from enum import Enum
|
||||||
|
from random import Random
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
RE_WHITESPACE = re.compile(
|
||||||
|
pattern=r"\s+",
|
||||||
|
flags=re.UNICODE | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
RE_SPECIAL_CHARS = re.compile(
|
||||||
|
pattern=r"[^a-zA-Z0-9\s]+",
|
||||||
|
flags=re.UNICODE | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedString(BaseModel):
|
||||||
|
class __Whitespace(str, Enum):
|
||||||
|
# unverändert
|
||||||
|
KEEP = "KEEP"
|
||||||
|
|
||||||
|
# Leerzeichen an Anfang und Ende entfernen
|
||||||
|
STRIP = "STRIP"
|
||||||
|
|
||||||
|
# whitespace durch Leerzeichen ersetzen
|
||||||
|
SPACE = "SPACE"
|
||||||
|
|
||||||
|
# whitespace entfernen
|
||||||
|
REMOVE = "REMOVE"
|
||||||
|
|
||||||
|
class __SpecialChars(str, Enum):
|
||||||
|
# unverändert
|
||||||
|
KEEP = "KEEP"
|
||||||
|
|
||||||
|
# Sonderzeichen entfernen
|
||||||
|
REMOVE = "REMOVE"
|
||||||
|
|
||||||
|
class __Case(str, Enum):
|
||||||
|
# unverändert
|
||||||
|
KEEP = "KEEP"
|
||||||
|
|
||||||
|
# GROSSBUCHSTABEN
|
||||||
|
UPPER = "UPPER"
|
||||||
|
|
||||||
|
# kleinbuchstaben
|
||||||
|
LOWER = "LOWER"
|
||||||
|
|
||||||
|
# ZuFÄllIg
|
||||||
|
RANDOM = "RANDOM"
|
||||||
|
|
||||||
|
value: str
|
||||||
|
|
||||||
|
whitespace: __Whitespace = __Whitespace.REMOVE
|
||||||
|
special_chars: __SpecialChars = __SpecialChars.REMOVE
|
||||||
|
case: __Case = __Case.UPPER
|
||||||
|
|
||||||
|
@field_validator("whitespace", "case", mode="before")
|
||||||
|
def transform_from_str(cls, v) -> str:
|
||||||
|
return str(v).upper()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clean(self) -> str:
|
||||||
|
result = self.value
|
||||||
|
|
||||||
|
# Whitespace verarbeiten
|
||||||
|
if self.whitespace is TransformedString.__Whitespace.STRIP:
|
||||||
|
result = result.strip()
|
||||||
|
|
||||||
|
elif self.whitespace is TransformedString.__Whitespace.SPACE:
|
||||||
|
result = RE_WHITESPACE.sub(string=result, repl=" ")
|
||||||
|
|
||||||
|
elif self.whitespace is TransformedString.__Whitespace.REMOVE:
|
||||||
|
result = RE_WHITESPACE.sub(string=result, repl="")
|
||||||
|
|
||||||
|
# Sonderzeichen verarbeiten
|
||||||
|
if self.special_chars is TransformedString.__SpecialChars.REMOVE:
|
||||||
|
result = RE_SPECIAL_CHARS.sub(string=result, repl="")
|
||||||
|
|
||||||
|
# Groß-/Kleinschreibung verarbeiten
|
||||||
|
if self.case is TransformedString.__Case.UPPER:
|
||||||
|
result = result.upper()
|
||||||
|
|
||||||
|
elif self.case is TransformedString.__Case.LOWER:
|
||||||
|
result = result.lower()
|
||||||
|
|
||||||
|
elif self.case is TransformedString.__Case.RANDOM:
|
||||||
|
rnd = Random(self.value)
|
||||||
|
|
||||||
|
def randomcase(c: str) -> str:
|
||||||
|
if rnd.choice((True, False)):
|
||||||
|
return c.upper()
|
||||||
|
return c.lower()
|
||||||
|
|
||||||
|
result = "".join(randomcase(c) for c in result)
|
||||||
|
|
||||||
|
return result
|
||||||
22
api/advent22_api/main.py
Normal file
22
api/advent22_api/main.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from .core.settings import SETTINGS
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""
|
||||||
|
If the `main` script is run, `uvicorn` is used to run the app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
app="advent22_api.app:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=not SETTINGS.production_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
8
api/advent22_api/routers/__init__.py
Normal file
8
api/advent22_api/routers/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from . import admin, user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
router.include_router(admin.router)
|
||||||
|
router.include_router(user.router)
|
||||||
65
api/advent22_api/routers/_security.py
Normal file
65
api/advent22_api/routers/_security.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import secrets
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
|
|
||||||
|
from ..core.config import Config, get_config
|
||||||
|
from ..core.depends import get_all_event_dates
|
||||||
|
from ..core.helpers import EventDates
|
||||||
|
|
||||||
|
security = HTTPBasic()
|
||||||
|
|
||||||
|
|
||||||
|
async def user_is_admin(
|
||||||
|
credentials: HTTPBasicCredentials = Depends(security),
|
||||||
|
cfg: Config = Depends(get_config),
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
True iff der user "admin" ist
|
||||||
|
"""
|
||||||
|
|
||||||
|
username_correct = secrets.compare_digest(
|
||||||
|
credentials.username.lower(),
|
||||||
|
cfg.admin.username.lower(),
|
||||||
|
)
|
||||||
|
password_correct = secrets.compare_digest(
|
||||||
|
credentials.password,
|
||||||
|
cfg.admin.password,
|
||||||
|
)
|
||||||
|
|
||||||
|
return username_correct and password_correct
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(
|
||||||
|
is_admin: bool = Depends(user_is_admin),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
HTTP 401 iff der user nicht "admin" ist
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not is_admin:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wie unhöflich!!!")
|
||||||
|
|
||||||
|
|
||||||
|
async def user_visible_days(
|
||||||
|
event_dates: EventDates = Depends(get_all_event_dates),
|
||||||
|
) -> list[int]:
|
||||||
|
"""
|
||||||
|
User-sichtbare Türchen
|
||||||
|
"""
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
return [event for event, date in event_dates.dates.items() if date <= today]
|
||||||
|
|
||||||
|
|
||||||
|
async def user_can_view_day(
|
||||||
|
day: int,
|
||||||
|
visible_days: list[int] = Depends(user_visible_days),
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
True iff das Türchen von Tag `day` user-sichtbar ist
|
||||||
|
"""
|
||||||
|
|
||||||
|
return day in visible_days
|
||||||
189
api/advent22_api/routers/admin.py
Normal file
189
api/advent22_api/routers/admin.py
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from advent22_api.core.helpers import EventDates
|
||||||
|
|
||||||
|
from ..core.calendar_config import (
|
||||||
|
CalendarConfig,
|
||||||
|
DoorsSaved,
|
||||||
|
get_calendar_config,
|
||||||
|
)
|
||||||
|
from ..core.config import Config, Image, get_config
|
||||||
|
from ..core.depends import (
|
||||||
|
TTFont,
|
||||||
|
get_all_event_dates,
|
||||||
|
get_all_image_names,
|
||||||
|
get_all_parts,
|
||||||
|
get_all_ttfonts,
|
||||||
|
)
|
||||||
|
from ..core.settings import SETTINGS, Credentials, RedisSettings
|
||||||
|
from ._security import require_admin, user_is_admin
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/is_admin")
|
||||||
|
async def is_admin(
|
||||||
|
is_admin: bool = Depends(user_is_admin),
|
||||||
|
) -> bool:
|
||||||
|
return is_admin
|
||||||
|
|
||||||
|
|
||||||
|
class AdminConfigModel(BaseModel):
|
||||||
|
class __Solution(BaseModel):
|
||||||
|
value: str
|
||||||
|
whitespace: str
|
||||||
|
special_chars: str
|
||||||
|
case: str
|
||||||
|
clean: str
|
||||||
|
|
||||||
|
class __Puzzle(BaseModel):
|
||||||
|
first: date
|
||||||
|
next: date | None
|
||||||
|
last: date
|
||||||
|
end: date
|
||||||
|
seed: str
|
||||||
|
extra_days: list[int]
|
||||||
|
skip_empty: bool
|
||||||
|
|
||||||
|
class __Calendar(BaseModel):
|
||||||
|
config_file: str
|
||||||
|
background: str
|
||||||
|
favicon: str
|
||||||
|
|
||||||
|
class __Font(BaseModel):
|
||||||
|
file: str
|
||||||
|
size: int
|
||||||
|
|
||||||
|
class __WebDAV(BaseModel):
|
||||||
|
url: str
|
||||||
|
cache_ttl: int
|
||||||
|
config_file: str
|
||||||
|
|
||||||
|
solution: __Solution
|
||||||
|
puzzle: __Puzzle
|
||||||
|
calendar: __Calendar
|
||||||
|
image: Image
|
||||||
|
fonts: list[__Font]
|
||||||
|
redis: RedisSettings
|
||||||
|
webdav: __WebDAV
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config_model")
|
||||||
|
async def get_config_model(
|
||||||
|
_: None = Depends(require_admin),
|
||||||
|
cfg: Config = Depends(get_config),
|
||||||
|
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||||
|
event_dates: EventDates = Depends(get_all_event_dates),
|
||||||
|
ttfonts: list[TTFont] = Depends(get_all_ttfonts),
|
||||||
|
) -> AdminConfigModel:
|
||||||
|
"""
|
||||||
|
Kombiniert aus privaten `settings`, `config` und `calendar_config`
|
||||||
|
"""
|
||||||
|
|
||||||
|
return AdminConfigModel.model_validate(
|
||||||
|
{
|
||||||
|
"solution": {
|
||||||
|
"value": cfg.solution.value,
|
||||||
|
"whitespace": cfg.solution.whitespace,
|
||||||
|
"special_chars": cfg.solution.special_chars,
|
||||||
|
"case": cfg.solution.case,
|
||||||
|
"clean": cfg.solution.clean,
|
||||||
|
},
|
||||||
|
"puzzle": {
|
||||||
|
"first": event_dates.first,
|
||||||
|
"next": event_dates.next,
|
||||||
|
"last": event_dates.last,
|
||||||
|
"end": event_dates.end,
|
||||||
|
"seed": cfg.random_seed,
|
||||||
|
"extra_days": sorted(cfg.puzzle.extra_days),
|
||||||
|
"skip_empty": cfg.puzzle.skip_empty,
|
||||||
|
},
|
||||||
|
"calendar": {
|
||||||
|
"config_file": cfg.calendar,
|
||||||
|
"background": cal_cfg.background,
|
||||||
|
"favicon": cal_cfg.favicon,
|
||||||
|
},
|
||||||
|
"image": cfg.image,
|
||||||
|
"fonts": [
|
||||||
|
{"file": ttfont.file_name, "size": ttfont.size} for ttfont in ttfonts
|
||||||
|
],
|
||||||
|
"redis": SETTINGS.redis,
|
||||||
|
"webdav": {
|
||||||
|
"url": SETTINGS.webdav.url,
|
||||||
|
"cache_ttl": SETTINGS.webdav.cache_ttl,
|
||||||
|
"config_file": SETTINGS.webdav.config_filename,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/day_image_names")
|
||||||
|
async def get_day_image_names(
|
||||||
|
_: None = Depends(require_admin),
|
||||||
|
image_names: dict[int, str] = Depends(get_all_image_names),
|
||||||
|
) -> dict[int, str]:
|
||||||
|
"""
|
||||||
|
Zuordnung der verwendeten Bilder zu den Tagen
|
||||||
|
"""
|
||||||
|
|
||||||
|
return image_names
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/day_parts")
|
||||||
|
async def get_day_parts(
|
||||||
|
_: None = Depends(require_admin),
|
||||||
|
parts: dict[int, str] = Depends(get_all_parts),
|
||||||
|
) -> dict[int, str]:
|
||||||
|
"""
|
||||||
|
Zuordnung der Lösungsteile zu den Tagen
|
||||||
|
"""
|
||||||
|
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/doors")
|
||||||
|
async def get_doors(
|
||||||
|
_: None = Depends(require_admin),
|
||||||
|
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||||
|
) -> DoorsSaved:
|
||||||
|
"""
|
||||||
|
Türchen lesen
|
||||||
|
"""
|
||||||
|
|
||||||
|
return cal_cfg.doors
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/doors")
|
||||||
|
async def put_doors(
|
||||||
|
doors: DoorsSaved,
|
||||||
|
_: None = Depends(require_admin),
|
||||||
|
cfg: Config = Depends(get_config),
|
||||||
|
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Türchen ändern
|
||||||
|
"""
|
||||||
|
|
||||||
|
cal_cfg.doors = sorted(
|
||||||
|
doors,
|
||||||
|
key=lambda door: door.day,
|
||||||
|
)
|
||||||
|
await cal_cfg.change(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credentials/{name}")
|
||||||
|
async def get_credentials(
|
||||||
|
name: str,
|
||||||
|
_: None = Depends(require_admin),
|
||||||
|
cfg: Config = Depends(get_config),
|
||||||
|
) -> Credentials:
|
||||||
|
|
||||||
|
if name == "dav":
|
||||||
|
return SETTINGS.webdav.auth
|
||||||
|
elif name == "ui":
|
||||||
|
return cfg.admin
|
||||||
|
else:
|
||||||
|
return Credentials()
|
||||||
109
api/advent22_api/routers/user.py
Normal file
109
api/advent22_api/routers/user.py
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from PIL.Image import Image
|
||||||
|
|
||||||
|
from ..core.calendar_config import (
|
||||||
|
CalendarConfig,
|
||||||
|
DoorsSaved,
|
||||||
|
get_calendar_config,
|
||||||
|
)
|
||||||
|
from ..core.config import Config, Site, get_config
|
||||||
|
from ..core.depends import get_all_event_dates, get_day_image
|
||||||
|
from ..core.helpers import (
|
||||||
|
EventDates,
|
||||||
|
ImageData,
|
||||||
|
api_return_ico,
|
||||||
|
api_return_jpeg,
|
||||||
|
load_image,
|
||||||
|
)
|
||||||
|
from ._security import user_can_view_day, user_is_admin, user_visible_days
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/user", tags=["user"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/background_image")
|
||||||
|
async def get_background_image(
|
||||||
|
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||||
|
) -> ImageData:
|
||||||
|
"""
|
||||||
|
Hintergrundbild laden
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await api_return_jpeg(await load_image(f"files/{cal_cfg.background}"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/favicon")
|
||||||
|
async def get_favicon(
|
||||||
|
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||||
|
) -> ImageData:
|
||||||
|
"""
|
||||||
|
Favicon laden
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await api_return_ico(await load_image(f"files/{cal_cfg.favicon}"))
|
||||||
|
|
||||||
|
except RuntimeError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/site_config")
|
||||||
|
async def get_site_config(
|
||||||
|
cfg: Config = Depends(get_config),
|
||||||
|
) -> Site:
|
||||||
|
"""
|
||||||
|
Seiteninhalt
|
||||||
|
"""
|
||||||
|
|
||||||
|
return cfg.site
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/doors")
|
||||||
|
async def get_doors(
|
||||||
|
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||||
|
visible_days: list[int] = Depends(user_visible_days),
|
||||||
|
) -> DoorsSaved:
|
||||||
|
"""
|
||||||
|
User-sichtbare Türchen lesen
|
||||||
|
"""
|
||||||
|
|
||||||
|
return [door for door in cal_cfg.doors if door.day in visible_days]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/image_{day}")
|
||||||
|
async def get_image_for_day(
|
||||||
|
user_can_view: bool = Depends(user_can_view_day),
|
||||||
|
is_admin: bool = Depends(user_is_admin),
|
||||||
|
image: Image | None = Depends(get_day_image),
|
||||||
|
) -> ImageData:
|
||||||
|
"""
|
||||||
|
Bild für einen Tag erstellen
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not (user_can_view or is_admin):
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wie unhöflich!!!")
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND, "Ich habe heute leider kein Foto für dich."
|
||||||
|
)
|
||||||
|
|
||||||
|
return await api_return_jpeg(image)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/next_door")
|
||||||
|
async def get_next_door(
|
||||||
|
event_dates: EventDates = Depends(get_all_event_dates),
|
||||||
|
) -> int | None:
|
||||||
|
"""
|
||||||
|
Zeit in ms, bis das nächste Türchen öffnet
|
||||||
|
"""
|
||||||
|
|
||||||
|
if event_dates.next is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
dt = datetime.combine(event_dates.next, datetime.min.time())
|
||||||
|
td = dt - datetime.now()
|
||||||
|
|
||||||
|
return int(td.total_seconds() * 1000)
|
||||||
1876
api/poetry.lock
generated
Normal file
1876
api/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
33
api/pyproject.toml
Normal file
33
api/pyproject.toml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
[tool.poetry]
|
||||||
|
authors = [
|
||||||
|
"Jörn-Michael Miehe <jmm@yavook.de>",
|
||||||
|
"Penner42 <unbekannt42@web.de>",
|
||||||
|
]
|
||||||
|
description = ""
|
||||||
|
license = "MIT"
|
||||||
|
name = "advent22_api"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
Pillow = "^12.1.1"
|
||||||
|
asyncify = "^0.12.1"
|
||||||
|
cachetools = "^7.0.1"
|
||||||
|
cachetoolsutils = "^11.0"
|
||||||
|
fastapi = "^0.129.0"
|
||||||
|
markdown = "^3.10.2"
|
||||||
|
numpy = "^2.4.2"
|
||||||
|
pydantic-settings = "^2.13.0"
|
||||||
|
python = ">=3.11,<3.15"
|
||||||
|
redis = {extras = ["hiredis"], version = "^7.1.1"}
|
||||||
|
tomli-w = "^1.2.0"
|
||||||
|
uvicorn = {extras = ["standard"], version = "^0.40.0"}
|
||||||
|
webdavclient3 = "^3.14.7"
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
black = "^26.1.0"
|
||||||
|
flake8 = "^7.3.0"
|
||||||
|
pytest = "^9.0.2"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
requires = ["poetry-core>=1.0.0"]
|
||||||
88
api/test/test_event_dates.py
Normal file
88
api/test/test_event_dates.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from advent22_api.core.helpers import EventDates
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_before():
|
||||||
|
today = date(2023, 11, 30)
|
||||||
|
|
||||||
|
ed = EventDates(
|
||||||
|
today=today,
|
||||||
|
begin_month=12,
|
||||||
|
begin_day=1,
|
||||||
|
events=list(range(1, 25)),
|
||||||
|
close_after=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ed.first == date(2023, 12, 1)
|
||||||
|
assert ed.get_next(today=today) == date(2023, 12, 1)
|
||||||
|
assert ed.last == date(2023, 12, 24)
|
||||||
|
assert ed.end == date(2023, 12, 29)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_after():
|
||||||
|
today = date(2023, 12, 30)
|
||||||
|
|
||||||
|
ed = EventDates(
|
||||||
|
today=today,
|
||||||
|
begin_month=12,
|
||||||
|
begin_day=1,
|
||||||
|
events=list(range(1, 25)),
|
||||||
|
close_after=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ed.first == date(2024, 12, 1)
|
||||||
|
assert ed.get_next(today=today) == date(2024, 12, 1)
|
||||||
|
assert ed.last == date(2024, 12, 24)
|
||||||
|
assert ed.end == date(2024, 12, 29)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_during_events():
|
||||||
|
today = date(2023, 12, 10)
|
||||||
|
|
||||||
|
ed = EventDates(
|
||||||
|
today=today,
|
||||||
|
begin_month=12,
|
||||||
|
begin_day=1,
|
||||||
|
events=list(range(1, 25)),
|
||||||
|
close_after=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ed.first == date(2023, 12, 1)
|
||||||
|
assert ed.get_next(today=today) == date(2023, 12, 11)
|
||||||
|
assert ed.last == date(2023, 12, 24)
|
||||||
|
assert ed.end == date(2023, 12, 29)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_during_closing():
|
||||||
|
today = date(2023, 12, 29)
|
||||||
|
|
||||||
|
ed = EventDates(
|
||||||
|
today=today,
|
||||||
|
begin_month=12,
|
||||||
|
begin_day=1,
|
||||||
|
events=list(range(1, 25)),
|
||||||
|
close_after=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ed.first == date(2023, 12, 1)
|
||||||
|
assert ed.get_next(today=today) is None
|
||||||
|
assert ed.last == date(2023, 12, 24)
|
||||||
|
assert ed.end == date(2023, 12, 29)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_during_wrap():
|
||||||
|
today = date(2024, 1, 1)
|
||||||
|
|
||||||
|
ed = EventDates(
|
||||||
|
today=today,
|
||||||
|
begin_month=12,
|
||||||
|
begin_day=1,
|
||||||
|
events=list(range(1, 25)),
|
||||||
|
close_after=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ed.first == date(2023, 12, 1)
|
||||||
|
assert ed.get_next(today=today) is None
|
||||||
|
assert ed.last == date(2023, 12, 24)
|
||||||
|
assert ed.end == date(2024, 1, 1)
|
||||||
77
api/test/test_spread.py
Normal file
77
api/test/test_spread.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
from advent22_api.core.helpers import spread
|
||||||
|
|
||||||
|
|
||||||
|
def test_easy() -> None:
|
||||||
|
assert spread([1, 4], 0) == []
|
||||||
|
assert spread([1, 4], 1) == [2]
|
||||||
|
assert spread([1, 4], 2) == [2, 3]
|
||||||
|
assert spread([1, 4], 5) == [2, 3, 1, 2, 3]
|
||||||
|
assert spread([1, 4], 10) == [2, 3, 1, 2, 3, 4, 1, 2, 3, 4]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tight() -> None:
|
||||||
|
assert spread([1, 2], 0) == []
|
||||||
|
assert spread([1, 2], 1) == [1]
|
||||||
|
assert spread([1, 2], 2) == [1, 2]
|
||||||
|
assert spread([1, 2], 5) == [1, 2, 1, 2, 1]
|
||||||
|
assert spread([1, 2], 10) == [1, 2, 1, 2, 1, 2, 1, 2, 1, 2]
|
||||||
|
|
||||||
|
assert spread([1, 2, 3, 4, 5], 0) == []
|
||||||
|
assert spread([1, 2, 3, 4, 5], 1) == [1]
|
||||||
|
assert spread([1, 2, 3, 4, 5], 2) == [1, 2]
|
||||||
|
assert spread([1, 2, 3, 4, 5], 5) == [1, 2, 3, 4, 5]
|
||||||
|
assert spread([1, 2, 3, 4, 5], 10) == [1, 2, 3, 4, 5, 1, 2, 3, 4, 5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_more_given() -> None:
|
||||||
|
assert spread([0, 5, 10], 0) == []
|
||||||
|
assert spread([0, 5, 10], 1) == [1]
|
||||||
|
assert spread([0, 5, 10], 2) == [1, 2]
|
||||||
|
assert spread([0, 5, 10], 5) == [1, 2, 3, 4, 6]
|
||||||
|
assert spread([0, 5, 10], 10) == [1, 2, 3, 4, 6, 7, 8, 9, 0, 1]
|
||||||
|
|
||||||
|
assert spread([0, 1, 2, 5, 10], 0) == []
|
||||||
|
assert spread([0, 1, 2, 5, 10], 1) == [3]
|
||||||
|
assert spread([0, 1, 2, 5, 10], 2) == [3, 4]
|
||||||
|
assert spread([0, 1, 2, 5, 10], 5) == [3, 4, 6, 7, 8]
|
||||||
|
assert spread([0, 1, 2, 5, 10], 10) == [3, 4, 6, 7, 8, 9, 0, 1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_given() -> None:
|
||||||
|
assert spread([0], 0) == []
|
||||||
|
assert spread([0], 1) == [1]
|
||||||
|
assert spread([0], 2) == [1, 2]
|
||||||
|
assert spread([0], 5) == [1, 2, 3, 4, 5]
|
||||||
|
assert spread([0], 10) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
|
|
||||||
|
assert spread([1], 0) == []
|
||||||
|
assert spread([1], 1) == [1]
|
||||||
|
assert spread([1], 2) == [1, 1]
|
||||||
|
assert spread([1], 5) == [1, 1, 1, 1, 1]
|
||||||
|
assert spread([1], 10) == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
|
||||||
|
|
||||||
|
assert spread([2], 0) == []
|
||||||
|
assert spread([2], 1) == [1]
|
||||||
|
assert spread([2], 2) == [1, 1]
|
||||||
|
assert spread([2], 5) == [1, 1, 2, 1, 2]
|
||||||
|
assert spread([2], 10) == [1, 1, 2, 1, 2, 1, 2, 1, 2, 1]
|
||||||
|
|
||||||
|
assert spread([5], 0) == []
|
||||||
|
assert spread([5], 1) == [1]
|
||||||
|
assert spread([5], 2) == [1, 2]
|
||||||
|
assert spread([5], 5) == [1, 2, 3, 4, 1]
|
||||||
|
assert spread([5], 10) == [1, 2, 3, 4, 1, 2, 3, 4, 5, 1]
|
||||||
|
|
||||||
|
assert spread([10], 0) == []
|
||||||
|
assert spread([10], 1) == [1]
|
||||||
|
assert spread([10], 2) == [1, 2]
|
||||||
|
assert spread([10], 5) == [1, 2, 3, 4, 5]
|
||||||
|
assert spread([10], 10) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_given() -> None:
|
||||||
|
assert spread([], 0) == []
|
||||||
|
assert spread([], 1) == [1]
|
||||||
|
assert spread([], 2) == [1, 2]
|
||||||
|
assert spread([], 5) == [1, 2, 3, 4, 5]
|
||||||
|
assert spread([], 10) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
61
scripts/check_version
Executable file
61
scripts/check_version
Executable file
|
|
@ -0,0 +1,61 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
script="$( readlink -f "${0}" )"
|
||||||
|
script_dir="$( dirname "${script}" )"
|
||||||
|
|
||||||
|
git rev-parse --abbrev-ref HEAD | grep -E '^develop$|^feature/' >/dev/null \
|
||||||
|
&& git_status="developing"
|
||||||
|
git rev-parse --abbrev-ref HEAD | grep -E '^release/|^hotfix/' >/dev/null \
|
||||||
|
&& git_status="releasing"
|
||||||
|
git rev-parse --abbrev-ref HEAD | grep -E '^master$' >/dev/null \
|
||||||
|
&& git_status="released"
|
||||||
|
|
||||||
|
|
||||||
|
if [ "${git_status}" = "developing" ]; then
|
||||||
|
echo "Status: Developing"
|
||||||
|
# => version from most recent tag
|
||||||
|
git_version="$( \
|
||||||
|
git describe --tags --abbrev=0 \
|
||||||
|
| sed -E 's/^v[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9]).*$/\1/'
|
||||||
|
)"
|
||||||
|
elif [ "${git_status}" = "releasing" ]; then
|
||||||
|
echo "Status: Releasing"
|
||||||
|
# => version from releasing branch
|
||||||
|
git_version="$( \
|
||||||
|
git rev-parse --abbrev-ref HEAD \
|
||||||
|
| cut -d '/' -f 2
|
||||||
|
)"
|
||||||
|
elif [ "${git_status}" = "released" ]; then
|
||||||
|
echo "Status: Released"
|
||||||
|
# => version from current tag
|
||||||
|
git_version="$( \
|
||||||
|
git describe --tags \
|
||||||
|
| sed -E 's/^v[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9])$/\1/'
|
||||||
|
)"
|
||||||
|
else
|
||||||
|
echo "ERROR: Invalid git branch"
|
||||||
|
echo "ERROR: Chores cannot be run on '$( git rev-parse --abbrev-ref HEAD )'!"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_version="$( \
|
||||||
|
grep '^version' "${script_dir}/../api/pyproject.toml" \
|
||||||
|
| sed -E 's/^version[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9]).*$/\1/'
|
||||||
|
)"
|
||||||
|
|
||||||
|
ui_version="$( \
|
||||||
|
grep '"version":' "${script_dir}/../ui/package.json" \
|
||||||
|
| sed -E 's/.*"version":[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9]).*$/\1/'
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [ "${git_version}" = "${api_version}" ] \
|
||||||
|
&& [ "${git_version}" = "${ui_version}" ]; then
|
||||||
|
mark="✅️"
|
||||||
|
else
|
||||||
|
mark="❌️"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "git: ${git_version}, api: ${api_version}, ui: ${ui_version}"
|
||||||
|
echo ">>>>> RESULT: ${mark} <<<<<"
|
||||||
|
|
||||||
|
[ "${mark}" = "✅️" ] || exit 1
|
||||||
67
scripts/mini-tiangolo/gunicorn_conf.py
Normal file
67
scripts/mini-tiangolo/gunicorn_conf.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import json
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
|
||||||
|
workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1")
|
||||||
|
max_workers_str = os.getenv("MAX_WORKERS")
|
||||||
|
use_max_workers = None
|
||||||
|
if max_workers_str:
|
||||||
|
use_max_workers = int(max_workers_str)
|
||||||
|
web_concurrency_str = os.getenv("WEB_CONCURRENCY", None)
|
||||||
|
|
||||||
|
host = os.getenv("HOST", "0.0.0.0")
|
||||||
|
port = os.getenv("PORT", "80")
|
||||||
|
bind_env = os.getenv("BIND", None)
|
||||||
|
use_loglevel = os.getenv("LOG_LEVEL", "info")
|
||||||
|
if bind_env:
|
||||||
|
use_bind = bind_env
|
||||||
|
else:
|
||||||
|
use_bind = f"{host}:{port}"
|
||||||
|
|
||||||
|
cores = multiprocessing.cpu_count()
|
||||||
|
workers_per_core = float(workers_per_core_str)
|
||||||
|
default_web_concurrency = workers_per_core * cores
|
||||||
|
if web_concurrency_str:
|
||||||
|
web_concurrency = int(web_concurrency_str)
|
||||||
|
assert web_concurrency > 0
|
||||||
|
else:
|
||||||
|
web_concurrency = max(int(default_web_concurrency), 2)
|
||||||
|
if use_max_workers:
|
||||||
|
web_concurrency = min(web_concurrency, use_max_workers)
|
||||||
|
accesslog_var = os.getenv("ACCESS_LOG", "-")
|
||||||
|
use_accesslog = accesslog_var or None
|
||||||
|
errorlog_var = os.getenv("ERROR_LOG", "-")
|
||||||
|
use_errorlog = errorlog_var or None
|
||||||
|
graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "120")
|
||||||
|
timeout_str = os.getenv("TIMEOUT", "120")
|
||||||
|
keepalive_str = os.getenv("KEEP_ALIVE", "5")
|
||||||
|
|
||||||
|
# Gunicorn config variables
|
||||||
|
loglevel = use_loglevel
|
||||||
|
workers = web_concurrency
|
||||||
|
bind = use_bind
|
||||||
|
errorlog = use_errorlog
|
||||||
|
worker_tmp_dir = "/dev/shm"
|
||||||
|
accesslog = use_accesslog
|
||||||
|
graceful_timeout = int(graceful_timeout_str)
|
||||||
|
timeout = int(timeout_str)
|
||||||
|
keepalive = int(keepalive_str)
|
||||||
|
|
||||||
|
|
||||||
|
# For debugging and testing
|
||||||
|
log_data = {
|
||||||
|
"loglevel": loglevel,
|
||||||
|
"workers": workers,
|
||||||
|
"bind": bind,
|
||||||
|
"graceful_timeout": graceful_timeout,
|
||||||
|
"timeout": timeout,
|
||||||
|
"keepalive": keepalive,
|
||||||
|
"errorlog": errorlog,
|
||||||
|
"accesslog": accesslog,
|
||||||
|
# Additional, non-gunicorn variables
|
||||||
|
"workers_per_core": workers_per_core,
|
||||||
|
"use_max_workers": use_max_workers,
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
}
|
||||||
|
print(json.dumps(log_data))
|
||||||
20
scripts/mini-tiangolo/start.sh
Normal file
20
scripts/mini-tiangolo/start.sh
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MODULE_NAME=${MODULE_NAME:-"app.main"}
|
||||||
|
VARIABLE_NAME=${VARIABLE_NAME:-"app"}
|
||||||
|
export APP_MODULE="${APP_MODULE:-"$MODULE_NAME:$VARIABLE_NAME"}"
|
||||||
|
export GUNICORN_CONF="${GUNICORN_CONF:-"/usr/local/share/uvicorn-gunicorn/gunicorn_conf.py"}"
|
||||||
|
export WORKER_CLASS="${WORKER_CLASS:-"uvicorn.workers.UvicornWorker"}"
|
||||||
|
|
||||||
|
if [ -f "${PRE_START_PATH}" ] ; then
|
||||||
|
echo "Running script ${PRE_START_PATH}"
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "${PRE_START_PATH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start Gunicorn
|
||||||
|
exec gunicorn \
|
||||||
|
-k "${WORKER_CLASS}" \
|
||||||
|
-c "${GUNICORN_CONF}" \
|
||||||
|
"${APP_MODULE}"
|
||||||
22
scripts/publish
Executable file
22
scripts/publish
Executable file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
script="$( readlink -f "${0}" )"
|
||||||
|
script_dir="$( dirname "${script}" )"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. "${script_dir}/check_version"
|
||||||
|
|
||||||
|
# vars defined in `check_version` script
|
||||||
|
# shellcheck disable=SC2154
|
||||||
|
if [ "${git_status}" = "releasing" ] || [ "${git_status}" = "released" ]; then
|
||||||
|
# shellcheck disable=SC2154
|
||||||
|
image_tag="${git_version}"
|
||||||
|
else
|
||||||
|
image_tag="latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
--pull --push \
|
||||||
|
--tag "code.lenaisten.de/lenaisten/advent22:${image_tag}" \
|
||||||
|
--platform "linux/amd64" \
|
||||||
|
"${script_dir}/.."
|
||||||
4
ui/.browserslistrc
Normal file
4
ui/.browserslistrc
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
|
not dead
|
||||||
|
not ie 11
|
||||||
48
ui/.devcontainer/devcontainer.json
Normal file
48
ui/.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||||
|
// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/javascript-node
|
||||||
|
{
|
||||||
|
"name": "Advent22 UI",
|
||||||
|
|
||||||
|
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/javascript-node:4-24-trixie",
|
||||||
|
|
||||||
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/git-lfs:1": {},
|
||||||
|
"ghcr.io/devcontainers-extra/features/apt-get-packages:1": {
|
||||||
|
"packages": "git-flow"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers-extra/features/vue-cli:2": {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
"customizations": {
|
||||||
|
// Configure properties specific to VS Code.
|
||||||
|
"vscode": {
|
||||||
|
// Set *default* container specific settings.json values on container create.
|
||||||
|
"settings": {
|
||||||
|
"terminal.integrated.defaultProfile.linux": "zsh"
|
||||||
|
},
|
||||||
|
// Add the IDs of extensions you want installed when the container is created.
|
||||||
|
"extensions": [
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"mhutchie.git-graph",
|
||||||
|
"Syler.sass-indented",
|
||||||
|
"Vue.volar"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
// "postCreateCommand": "yarn install",
|
||||||
|
|
||||||
|
// Use 'postStartCommand' to run commands after the container is started.
|
||||||
|
"postStartCommand": "yarn dlx update-browserslist-db@latest && yarn install"
|
||||||
|
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
// "forwardPorts": [],
|
||||||
|
|
||||||
|
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
// "remoteUser": "root"
|
||||||
|
}
|
||||||
38
ui/.eslintrc.js
Normal file
38
ui/.eslintrc.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
extends: [
|
||||||
|
"plugin:vue/vue3-essential",
|
||||||
|
"eslint:recommended",
|
||||||
|
"@vue/typescript/recommended",
|
||||||
|
],
|
||||||
|
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
},
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
"no-empty": "off",
|
||||||
|
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
|
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
|
},
|
||||||
|
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
"**/__tests__/*.{j,t}s?(x)",
|
||||||
|
"**/tests/unit/**/*.spec.{j,t}s?(x)",
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
mocha: true,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-expressions": "off",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
149
ui/.gitignore
vendored
Normal file
149
ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# https://raw.githubusercontent.com/github/gitignore/refs/heads/main/Node.gitignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
.output
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Sveltekit cache directory
|
||||||
|
.svelte-kit/
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# Firebase cache directory
|
||||||
|
.firebase/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
# yarn v3
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# Vite files
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
.vite/
|
||||||
3
ui/.vscode/extensions.json
vendored
Normal file
3
ui/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["sdras.vue-vscode-snippets"]
|
||||||
|
}
|
||||||
15
ui/.vscode/launch.json
vendored
Normal file
15
ui/.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
// Verwendet IntelliSense zum Ermitteln möglicher Attribute.
|
||||||
|
// Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen.
|
||||||
|
// Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Chrome mit Advent22 UI starten",
|
||||||
|
"url": "http://localhost:8080",
|
||||||
|
"webRoot": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
ui/.vscode/settings.json
vendored
Normal file
23
ui/.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"[scss][vue][typescript][javascript][json][jsonc][jsonl]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
|
||||||
|
"[jsonc]": {
|
||||||
|
"editor.formatOnSave": false,
|
||||||
|
},
|
||||||
|
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports": "explicit"
|
||||||
|
},
|
||||||
|
|
||||||
|
"git.closeDiffOnOperation": true,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
|
||||||
|
"sass.disableAutoIndent": true,
|
||||||
|
"sass.format.convert": false,
|
||||||
|
"sass.format.deleteWhitespace": true,
|
||||||
|
|
||||||
|
"prettier.trailingComma": "all",
|
||||||
|
}
|
||||||
12
ui/.vscode/tasks.json
vendored
Normal file
12
ui/.vscode/tasks.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "serve",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"label": "UI starten",
|
||||||
|
"detail": "vue-cli-service serve"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
942
ui/.yarn/releases/yarn-4.12.0.cjs
vendored
Executable file
942
ui/.yarn/releases/yarn-4.12.0.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
2
ui/.yarnrc.yml
Normal file
2
ui/.yarnrc.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
nodeLinker: node-modules
|
||||||
|
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||||
29
ui/README.md
Normal file
29
ui/README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# advent22_ui
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and hot-reloads for development
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and minifies for production
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lints and fixes files
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customize configuration
|
||||||
|
|
||||||
|
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||||
5
ui/babel.config.json
Normal file
5
ui/babel.config.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
"@vue/cli-plugin-babel/preset"
|
||||||
|
]
|
||||||
|
}
|
||||||
47
ui/package.json
Normal file
47
ui/package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"name": "advent22_ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "yarn@4.12.0",
|
||||||
|
"scripts": {
|
||||||
|
"serve": "vue-cli-service serve",
|
||||||
|
"build": "vue-cli-service build",
|
||||||
|
"test:unit": "vue-cli-service test:unit",
|
||||||
|
"test:unit-watch": "vue-cli-service test:unit --watch",
|
||||||
|
"lint": "vue-cli-service lint",
|
||||||
|
"ui": "vue ui --host 0.0.0.0 --headless"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
|
"@fortawesome/vue-fontawesome": "^3.1.3",
|
||||||
|
"@types/chai": "^5.2.3",
|
||||||
|
"@types/luxon": "^3.7.1",
|
||||||
|
"@types/mocha": "^10.0.10",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.55.0",
|
||||||
|
"@typescript-eslint/parser": "^8.55.0",
|
||||||
|
"@vue/cli-plugin-babel": "^5.0.9",
|
||||||
|
"@vue/cli-plugin-eslint": "^5.0.9",
|
||||||
|
"@vue/cli-plugin-typescript": "^5.0.9",
|
||||||
|
"@vue/cli-plugin-unit-mocha": "^5.0.9",
|
||||||
|
"@vue/cli-service": "^5.0.9",
|
||||||
|
"@vue/eslint-config-typescript": "^13.0.0",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"animate.css": "^4.1.1",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"bulma": "^1.0.4",
|
||||||
|
"bulma-toast": "2.4.3",
|
||||||
|
"chai": "^6.2.2",
|
||||||
|
"core-js": "^3.48.0",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-plugin-vue": "^9.33.0",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"sass": "~1.94.3",
|
||||||
|
"sass-loader": "^16.0.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vue": "^3.5.25",
|
||||||
|
"vue-cli-plugin-webpack-bundle-analyzer": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ui/public/favicon.ico
Normal file
BIN
ui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
40
ui/public/index.html
Normal file
40
ui/public/index.html
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
|
||||||
|
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||||
|
<!-- Matomo -->
|
||||||
|
<script>
|
||||||
|
let _paq = (window._paq = window._paq || []);
|
||||||
|
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
|
||||||
|
_paq.push(["trackPageView"]);
|
||||||
|
_paq.push(["enableLinkTracking"]);
|
||||||
|
(function () {
|
||||||
|
const u = "https://stats.kiwi.lenaisten.de/";
|
||||||
|
_paq.push(["setTrackerUrl", u + "matomo.php"]);
|
||||||
|
_paq.push(["setSiteId", "10"]);
|
||||||
|
const d = document,
|
||||||
|
g = d.createElement("script"),
|
||||||
|
s = d.getElementsByTagName("script")[0];
|
||||||
|
g.async = true;
|
||||||
|
g.src = u + "matomo.js";
|
||||||
|
s.parentNode.insertBefore(g, s);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<!-- End Matomo Code -->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<strong
|
||||||
|
>Es tut uns leid, aber <%= htmlWebpackPlugin.options.title %>
|
||||||
|
funktioniert nicht richtig ohne JavaScript. Bitte aktivieren Sie es, um
|
||||||
|
fortzufahren.</strong
|
||||||
|
>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- built files will be auto injected -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
67
ui/src/App.vue
Normal file
67
ui/src/App.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<template>
|
||||||
|
<section class="hero is-small is-primary">
|
||||||
|
<div class="hero-body">
|
||||||
|
<h1 class="title is-uppercase">{{ store.site_config.title }}</h1>
|
||||||
|
<h2 class="subtitle">{{ store.site_config.subtitle }}</h2>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section px-3">
|
||||||
|
<progress
|
||||||
|
v-if="store.background_image === 'loading'"
|
||||||
|
class="progress is-primary"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else-if="store.background_image === 'error'"
|
||||||
|
class="notification is-danger"
|
||||||
|
>
|
||||||
|
Hintergrundbild konnte nicht geladen werden
|
||||||
|
</div>
|
||||||
|
<div v-else class="container">
|
||||||
|
<AdminView v-if="store.is_admin" />
|
||||||
|
<UserView v-else />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="is-flex-grow-1" />
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="level">
|
||||||
|
<div class="level-item">
|
||||||
|
<p v-html="store.site_config.footer" />
|
||||||
|
</div>
|
||||||
|
<div class="level-right">
|
||||||
|
<div class="level-item">
|
||||||
|
<TouchButton class="is-small is-warning" />
|
||||||
|
</div>
|
||||||
|
<div class="level-item">
|
||||||
|
<AdminButton class="is-small is-link is-outlined" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { advent22Store } from "./lib/store";
|
||||||
|
|
||||||
|
import AdminView from "./components/admin/AdminView.vue";
|
||||||
|
import AdminButton from "./components/AdminButton.vue";
|
||||||
|
import TouchButton from "./components/TouchButton.vue";
|
||||||
|
import UserView from "./components/UserView.vue";
|
||||||
|
|
||||||
|
const store = advent22Store();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
ui/src/assets/logo.png
(Stored with Git LFS)
Normal file
BIN
ui/src/assets/logo.png
(Stored with Git LFS)
Normal file
Binary file not shown.
14
ui/src/bulma-scheme.scss
Normal file
14
ui/src/bulma-scheme.scss
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
@charset "utf-8";
|
||||||
|
|
||||||
|
//=====================
|
||||||
|
// custom color scheme
|
||||||
|
//=====================
|
||||||
|
|
||||||
|
$colors: (
|
||||||
|
"primary": #945de1,
|
||||||
|
"link": #64b4bd,
|
||||||
|
"info": #8c4e80,
|
||||||
|
"success": #7e8e2b,
|
||||||
|
"warning": #f6ca6b,
|
||||||
|
"danger": #c5443b,
|
||||||
|
);
|
||||||
52
ui/src/components/AdminButton.vue
Normal file
52
ui/src/components/AdminButton.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<template>
|
||||||
|
<LoginModal v-if="modal_visible" @submit="on_submit" @cancel="on_cancel" />
|
||||||
|
|
||||||
|
<BulmaButton
|
||||||
|
v-bind="$attrs"
|
||||||
|
:icon="['fas', store.is_admin ? 'fa-toggle-on' : 'fa-toggle-off']"
|
||||||
|
:busy="is_busy"
|
||||||
|
text="Admin"
|
||||||
|
@click.left="on_click"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { APIError } from "@/lib/api_error";
|
||||||
|
import type { Credentials } from "@/lib/model";
|
||||||
|
import { advent22Store } from "@/lib/store";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
import BulmaButton from "./bulma/Button.vue";
|
||||||
|
import LoginModal from "./LoginModal.vue";
|
||||||
|
|
||||||
|
const modal_visible = ref(false);
|
||||||
|
const is_busy = ref(false);
|
||||||
|
const store = advent22Store();
|
||||||
|
|
||||||
|
function on_click(): void {
|
||||||
|
if (store.is_admin) {
|
||||||
|
store.logout();
|
||||||
|
} else {
|
||||||
|
// show login modal
|
||||||
|
is_busy.value = true;
|
||||||
|
modal_visible.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function on_submit(creds: Credentials): Promise<void> {
|
||||||
|
modal_visible.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.login(creds);
|
||||||
|
} catch (error) {
|
||||||
|
APIError.alert(error);
|
||||||
|
} finally {
|
||||||
|
is_busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function on_cancel(): void {
|
||||||
|
modal_visible.value = false;
|
||||||
|
is_busy.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
110
ui/src/components/Calendar.vue
Normal file
110
ui/src/components/Calendar.vue
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
<!-- eslint-disable vue/multi-word-component-names -->
|
||||||
|
<template>
|
||||||
|
<MultiModal @handle="on_modal_handle" />
|
||||||
|
|
||||||
|
<BulmaToast @handle="on_toast_handle" class="content">
|
||||||
|
<p>
|
||||||
|
Du hast noch keine Türchen geöffnet, vielleicht gibt es ein Anzeigeproblem
|
||||||
|
in Deinem Webbrowser?
|
||||||
|
</p>
|
||||||
|
<div class="level">
|
||||||
|
<div class="level-item">
|
||||||
|
<BulmaButton
|
||||||
|
class="is-success"
|
||||||
|
text="Türchen anzeigen"
|
||||||
|
@click.left="
|
||||||
|
store.is_touch_device = true;
|
||||||
|
toast?.hide();
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="level-item">
|
||||||
|
<BulmaButton
|
||||||
|
class="is-danger"
|
||||||
|
text="Ich möchte selbst suchen"
|
||||||
|
@click.left="toast?.hide()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BulmaToast>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<div class="image is-unselectable">
|
||||||
|
<img :src="unwrap_loading(store.background_image).data_url" />
|
||||||
|
<ThouCanvas>
|
||||||
|
<CalendarDoor
|
||||||
|
v-for="(door, index) in doors"
|
||||||
|
:key="`door-${index}`"
|
||||||
|
:door="door"
|
||||||
|
:visible="store.is_touch_device"
|
||||||
|
:title="name_door(door.day)"
|
||||||
|
@click="door_click(door.day)"
|
||||||
|
style="cursor: pointer"
|
||||||
|
/>
|
||||||
|
</ThouCanvas>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { API } from "@/lib/api";
|
||||||
|
import { APIError } from "@/lib/api_error";
|
||||||
|
import { type VueLike, name_door, unwrap_loading } from "@/lib/helpers";
|
||||||
|
import type { ImageData } from "@/lib/model";
|
||||||
|
import { Door } from "@/lib/rects/door";
|
||||||
|
import { advent22Store } from "@/lib/store";
|
||||||
|
|
||||||
|
import { onBeforeUnmount } from "vue";
|
||||||
|
import MultiModal, { type HMultiModal } from "./MultiModal.vue";
|
||||||
|
import BulmaButton from "./bulma/Button.vue";
|
||||||
|
import BulmaToast, { type HBulmaToast } from "./bulma/Toast.vue";
|
||||||
|
import CalendarDoor from "./calendar/CalendarDoor.vue";
|
||||||
|
import ThouCanvas from "./calendar/ThouCanvas.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
doors: VueLike<Door>[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const store = advent22Store();
|
||||||
|
|
||||||
|
let modal: HMultiModal | undefined;
|
||||||
|
let toast: HBulmaToast | undefined;
|
||||||
|
let toast_timeout: number | undefined;
|
||||||
|
|
||||||
|
function on_modal_handle(handle: HMultiModal): void {
|
||||||
|
modal = handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function on_toast_handle(handle: HBulmaToast): void {
|
||||||
|
toast = handle;
|
||||||
|
|
||||||
|
if (store.is_touch_device) return;
|
||||||
|
|
||||||
|
store.when_initialized(() => {
|
||||||
|
toast_timeout = window.setTimeout(() => {
|
||||||
|
if (store.user_doors.length === 0) return;
|
||||||
|
if (store.is_touch_device) return;
|
||||||
|
|
||||||
|
toast!.show({ duration: 600000, type: "is-warning" });
|
||||||
|
}, 10e3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function door_click(day: number): Promise<void> {
|
||||||
|
window.clearTimeout(toast_timeout);
|
||||||
|
toast?.hide();
|
||||||
|
|
||||||
|
if (modal === undefined) return;
|
||||||
|
modal.show_loading();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const day_image = await API.request<ImageData>(`user/image_${day}`);
|
||||||
|
modal.show_image(day_image.data_url, name_door(day));
|
||||||
|
} catch (error) {
|
||||||
|
APIError.alert(error);
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => toast?.hide());
|
||||||
|
</script>
|
||||||
48
ui/src/components/CountDown.vue
Normal file
48
ui/src/components/CountDown.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<template>
|
||||||
|
{{ string_repr }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Duration } from "luxon";
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
until: number;
|
||||||
|
tick_time?: number;
|
||||||
|
}>(),
|
||||||
|
{ tick_time: 200 },
|
||||||
|
);
|
||||||
|
|
||||||
|
let interval_id: number | undefined;
|
||||||
|
const string_repr = ref("");
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
function tick(): void {
|
||||||
|
const distance_ms = props.until - Date.now();
|
||||||
|
|
||||||
|
if (distance_ms <= 0) {
|
||||||
|
string_repr.value = "Jetzt!";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = Duration.fromMillis(distance_ms);
|
||||||
|
const d_days = distance.shiftTo("day").mapUnits(Math.floor);
|
||||||
|
const d_hms = distance.minus(d_days).shiftTo("hour", "minute", "second");
|
||||||
|
|
||||||
|
if (d_days.days > 0) {
|
||||||
|
string_repr.value = d_days.toHuman() + " ";
|
||||||
|
} else {
|
||||||
|
string_repr.value = "";
|
||||||
|
}
|
||||||
|
string_repr.value += d_hms.toFormat("hh:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
tick();
|
||||||
|
interval_id = window.setInterval(tick, props.tick_time);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.clearInterval(interval_id);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
93
ui/src/components/LoginModal.vue
Normal file
93
ui/src/components/LoginModal.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<template>
|
||||||
|
<div class="modal is-active">
|
||||||
|
<div class="modal-background" />
|
||||||
|
|
||||||
|
<div class="modal-card">
|
||||||
|
<header class="modal-card-head">
|
||||||
|
<p class="modal-card-title">Login</p>
|
||||||
|
<button class="delete" @click.left="cancel" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="modal-card-body">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Username</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
ref="username_input"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
v-model="creds.username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Passwort</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="password" v-model="creds.password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="modal-card-foot is-flex is-justify-content-space-around">
|
||||||
|
<BulmaButton
|
||||||
|
class="is-success"
|
||||||
|
@click.left="submit"
|
||||||
|
:icon="['fas', 'fa-unlock']"
|
||||||
|
text="Login"
|
||||||
|
/>
|
||||||
|
<BulmaButton
|
||||||
|
class="is-danger"
|
||||||
|
@click.left="cancel"
|
||||||
|
:icon="['fas', 'fa-circle-xmark']"
|
||||||
|
text="Abbrechen"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { wait_for } from "@/lib/helpers";
|
||||||
|
import type { Credentials } from "@/lib/model";
|
||||||
|
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue";
|
||||||
|
import BulmaButton from "./bulma/Button.vue";
|
||||||
|
|
||||||
|
const username_input = useTemplateRef("username_input");
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "submit", creds: Credentials): void;
|
||||||
|
(event: "cancel"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const creds = ref<Credentials>({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
emit("submit", creds.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(): void {
|
||||||
|
emit("cancel");
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const on_keydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") submit();
|
||||||
|
else if (e.key === "Escape") cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", on_keydown);
|
||||||
|
|
||||||
|
wait_for(
|
||||||
|
() => username_input.value !== null,
|
||||||
|
() => username_input.value!.focus(),
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("keydown", on_keydown);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
79
ui/src/components/MultiModal.vue
Normal file
79
ui/src/components/MultiModal.vue
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="state.show !== 'none'" class="modal is-active" @click="dismiss()">
|
||||||
|
<div class="modal-background" />
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-height: 100vh; max-width: 95vw">
|
||||||
|
<template v-if="state.show === 'loading'">
|
||||||
|
<progress class="progress is-primary" max="100" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="state.show === 'image'">
|
||||||
|
<figure>
|
||||||
|
<figcaption class="tag is-primary">
|
||||||
|
{{ state.caption }}
|
||||||
|
</figcaption>
|
||||||
|
<div class="image is-square">
|
||||||
|
<img :src="state.src" alt="Kalender-Bild" />
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="state.show !== 'loading'"
|
||||||
|
class="modal-close is-large has-background-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
type ModalState =
|
||||||
|
| { show: "none" }
|
||||||
|
| { show: "loading" }
|
||||||
|
| { show: "image"; src: string; caption: string };
|
||||||
|
|
||||||
|
const state = ref<ModalState>({ show: "none" });
|
||||||
|
|
||||||
|
export type HMultiModal = {
|
||||||
|
show_image(src: string, caption: string): void;
|
||||||
|
show_loading(): void;
|
||||||
|
hide(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "handle", handle: HMultiModal): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function hide(): void {
|
||||||
|
state.value = { show: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(): void {
|
||||||
|
if (state.value.show !== "loading") {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
emit("handle", {
|
||||||
|
show_image(src: string, caption: string = ""): void {
|
||||||
|
state.value = { show: "image", src: src, caption: caption };
|
||||||
|
},
|
||||||
|
show_loading(): void {
|
||||||
|
state.value = { show: "loading" };
|
||||||
|
},
|
||||||
|
hide,
|
||||||
|
});
|
||||||
|
|
||||||
|
const on_keydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") dismiss();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", on_keydown);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("keydown", on_keydown);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
17
ui/src/components/TouchButton.vue
Normal file
17
ui/src/components/TouchButton.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<template>
|
||||||
|
<span>Eingabemodus: </span>
|
||||||
|
<BulmaButton
|
||||||
|
v-bind="$attrs"
|
||||||
|
:icon="['fas', store.is_touch_device ? 'hand-pointer' : 'arrow-pointer']"
|
||||||
|
:text="store.is_touch_device ? 'Touch' : 'Desktop'"
|
||||||
|
@click.left="store.toggle_touch_device"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { advent22Store } from "@/lib/store";
|
||||||
|
|
||||||
|
import BulmaButton from "./bulma/Button.vue";
|
||||||
|
|
||||||
|
const store = advent22Store();
|
||||||
|
</script>
|
||||||
29
ui/src/components/UserView.vue
Normal file
29
ui/src/components/UserView.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<template>
|
||||||
|
<Calendar :doors="store.user_doors" />
|
||||||
|
<hr />
|
||||||
|
<div class="content" v-html="store.site_config.content" />
|
||||||
|
<div class="content has-text-primary">
|
||||||
|
<template v-if="store.next_door_target === null">
|
||||||
|
Alle {{ store.user_doors.length }} Türchen offen!
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<template v-if="store.user_doors.length === 0">
|
||||||
|
Zeit bis zum ersten Türchen:
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ store.user_doors.length }} Türchen offen. Zeit bis zum nächsten
|
||||||
|
Türchen:
|
||||||
|
</template>
|
||||||
|
<CountDown :until="store.next_door_target" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { advent22Store } from "@/lib/store";
|
||||||
|
|
||||||
|
import Calendar from "./Calendar.vue";
|
||||||
|
import CountDown from "./CountDown.vue";
|
||||||
|
|
||||||
|
const store = advent22Store();
|
||||||
|
</script>
|
||||||
20
ui/src/components/admin/AdminView.vue
Normal file
20
ui/src/components/admin/AdminView.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<template>
|
||||||
|
<ConfigView />
|
||||||
|
<CalendarAssistant />
|
||||||
|
<DoorMapEditor />
|
||||||
|
<BulmaDrawer header="Vorschau" :opening="store.update" refreshable>
|
||||||
|
<UserView />
|
||||||
|
</BulmaDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { advent22Store } from "@/lib/store";
|
||||||
|
|
||||||
|
import UserView from "../UserView.vue";
|
||||||
|
import BulmaDrawer from "../bulma/Drawer.vue";
|
||||||
|
import CalendarAssistant from "./CalendarAssistant.vue";
|
||||||
|
import ConfigView from "./ConfigView.vue";
|
||||||
|
import DoorMapEditor from "./DoorMapEditor.vue";
|
||||||
|
|
||||||
|
const store = advent22Store();
|
||||||
|
</script>
|
||||||
100
ui/src/components/admin/CalendarAssistant.vue
Normal file
100
ui/src/components/admin/CalendarAssistant.vue
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<template>
|
||||||
|
<MultiModal @handle="on_modal_handle" />
|
||||||
|
|
||||||
|
<BulmaDrawer header="Kalender-Assistent" :opening="on_open" refreshable>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="content">
|
||||||
|
<p>Hervorgehobenen Tagen wurde kein Buchstabe zugewiesen.</p>
|
||||||
|
|
||||||
|
<h3>Zuordnung Buchstaben</h3>
|
||||||
|
<div class="tags are-medium">
|
||||||
|
<template v-for="(data, day) in day_data" :key="`part-${day}`">
|
||||||
|
<span v-if="data.part === ''" class="tag is-warning">
|
||||||
|
{{ day }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="tag is-info">
|
||||||
|
{{ day }}: {{ data.part.split("").join(", ") }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Zuordnung Bilder</h3>
|
||||||
|
<div class="tags are-medium">
|
||||||
|
<span
|
||||||
|
v-for="(data, day) in day_data"
|
||||||
|
:key="`image-${day}`"
|
||||||
|
:class="'tag is-' + (data.part === '' ? 'warning' : 'primary')"
|
||||||
|
>
|
||||||
|
{{ day }}: {{ data.image_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Alle Türchen</h3>
|
||||||
|
<div class="tags are-medium">
|
||||||
|
<BulmaButton
|
||||||
|
v-for="(data, day) in day_data"
|
||||||
|
:key="`btn-${day}`"
|
||||||
|
:class="'tag is-' + (data.part === '' ? 'warning' : 'info')"
|
||||||
|
:icon="['fas', 'fa-door-open']"
|
||||||
|
:text="day.toString()"
|
||||||
|
@click.left="door_click(Number(day))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BulmaDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { API } from "@/lib/api";
|
||||||
|
import { name_door, objForEach } from "@/lib/helpers";
|
||||||
|
import type { ImageData, NumStrDict } from "@/lib/model";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
import MultiModal, { type HMultiModal } from "../MultiModal.vue";
|
||||||
|
import BulmaButton from "../bulma/Button.vue";
|
||||||
|
import BulmaDrawer from "../bulma/Drawer.vue";
|
||||||
|
|
||||||
|
const day_data = ref<Record<number, { part: string; image_name: string }>>({});
|
||||||
|
|
||||||
|
let modal: HMultiModal | undefined;
|
||||||
|
|
||||||
|
function on_modal_handle(handle: HMultiModal): void {
|
||||||
|
modal = handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function on_open(): Promise<void> {
|
||||||
|
const [day_parts, day_image_names] = await Promise.all([
|
||||||
|
API.request<NumStrDict>("admin/day_parts"),
|
||||||
|
API.request<NumStrDict>("admin/day_image_names"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const _ensure_day_in_data = (day: number) => {
|
||||||
|
if (!(day in day_data.value)) {
|
||||||
|
day_data.value[day] = { part: "", image_name: "" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
objForEach(day_parts, (day, part) => {
|
||||||
|
_ensure_day_in_data(day);
|
||||||
|
day_data.value[day].part = part;
|
||||||
|
});
|
||||||
|
|
||||||
|
objForEach(day_image_names, (day, image_name) => {
|
||||||
|
_ensure_day_in_data(day);
|
||||||
|
day_data.value[day].image_name = image_name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function door_click(day: number): Promise<void> {
|
||||||
|
if (modal === undefined) return;
|
||||||
|
modal.show_loading();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const day_image = await API.request<ImageData>(`user/image_${day}`);
|
||||||
|
modal.show_image(day_image.data_url, name_door(day));
|
||||||
|
} catch {
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
301
ui/src/components/admin/ConfigView.vue
Normal file
301
ui/src/components/admin/ConfigView.vue
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
<template>
|
||||||
|
<BulmaDrawer header="Konfiguration" :opening="on_open" refreshable>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-one-third">
|
||||||
|
<div class="content">
|
||||||
|
<h3>Lösung</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Wert</dt>
|
||||||
|
<dd>
|
||||||
|
Eingabe:
|
||||||
|
<span class="is-family-monospace">
|
||||||
|
"{{ admin_config_model.solution.value }}"
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
Ausgabe:
|
||||||
|
<span class="is-family-monospace">
|
||||||
|
"{{ admin_config_model.solution.clean }}"
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Transformation</dt>
|
||||||
|
<dd>
|
||||||
|
Whitespace:
|
||||||
|
<span class="is-uppercase is-family-monospace">
|
||||||
|
{{ admin_config_model.solution.whitespace }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
Sonderzeichen:
|
||||||
|
<span class="is-uppercase is-family-monospace">
|
||||||
|
{{ admin_config_model.solution.special_chars }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
Buchstaben:
|
||||||
|
<span class="is-uppercase is-family-monospace">
|
||||||
|
{{ admin_config_model.solution.case }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3>Rätsel</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Offene Türchen</dt>
|
||||||
|
<dd>{{ store.user_doors.length }}</dd>
|
||||||
|
|
||||||
|
<dt>Zeit zum nächsten Türchen</dt>
|
||||||
|
<dd v-if="store.next_door_target === null">
|
||||||
|
Kein nächstes Türchen
|
||||||
|
</dd>
|
||||||
|
<dd v-else><CountDown :until="store.next_door_target" /></dd>
|
||||||
|
|
||||||
|
<dt>Erstes Türchen</dt>
|
||||||
|
<dd>{{ fmt_puzzle_date("first") }}</dd>
|
||||||
|
|
||||||
|
<dt>Nächstes Türchen</dt>
|
||||||
|
<dd>{{ fmt_puzzle_date("next") }}</dd>
|
||||||
|
|
||||||
|
<dt>Letztes Türchen</dt>
|
||||||
|
<dd>{{ fmt_puzzle_date("last") }}</dd>
|
||||||
|
|
||||||
|
<dt>Rätsel schließt nach</dt>
|
||||||
|
<dd>{{ fmt_puzzle_date("end") }}</dd>
|
||||||
|
|
||||||
|
<dt>Zufalls-Seed</dt>
|
||||||
|
<dd class="is-family-monospace">
|
||||||
|
"{{ admin_config_model.puzzle.seed }}"
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Extra-Tage</dt>
|
||||||
|
<dd>
|
||||||
|
<template
|
||||||
|
v-for="(day, index) in admin_config_model.puzzle.extra_days"
|
||||||
|
:key="`extra_day-${index}`"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<template v-if="index > 0">, </template>
|
||||||
|
{{ day }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Leere Türchen</dt>
|
||||||
|
<dd v-if="admin_config_model.puzzle.skip_empty">Überspringen</dd>
|
||||||
|
<dd v-else>Anzeigen</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-one-third">
|
||||||
|
<div class="content">
|
||||||
|
<h3>Kalender</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Definition</dt>
|
||||||
|
<dd>{{ admin_config_model.calendar.config_file }}</dd>
|
||||||
|
|
||||||
|
<dt>Hintergrundbild</dt>
|
||||||
|
<dd>{{ admin_config_model.calendar.background }}</dd>
|
||||||
|
|
||||||
|
<dt>Favicon</dt>
|
||||||
|
<dd>{{ admin_config_model.calendar.favicon }}</dd>
|
||||||
|
|
||||||
|
<dt>Türchen ({{ doors.length }} Stück)</dt>
|
||||||
|
<dd>
|
||||||
|
<template v-for="(door, index) in doors" :key="`door-${index}`">
|
||||||
|
<span>
|
||||||
|
<template v-if="index > 0">, </template>
|
||||||
|
{{ door.day }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3>Bilder</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Größe</dt>
|
||||||
|
<dd>{{ admin_config_model.image.size }} px</dd>
|
||||||
|
|
||||||
|
<dt>Rand</dt>
|
||||||
|
<dd>{{ admin_config_model.image.border }} px</dd>
|
||||||
|
|
||||||
|
<dt>Schriftarten</dt>
|
||||||
|
<dd
|
||||||
|
v-for="(font, index) in admin_config_model.fonts"
|
||||||
|
:key="`font-${index}`"
|
||||||
|
>
|
||||||
|
{{ font.file }} ({{ font.size }} pt)
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-one-third">
|
||||||
|
<div class="content">
|
||||||
|
<h3>WebDAV</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>URL</dt>
|
||||||
|
<dd>{{ admin_config_model.webdav.url }}</dd>
|
||||||
|
|
||||||
|
<dt>Zugangsdaten</dt>
|
||||||
|
<dd class="is-family-monospace">
|
||||||
|
<BulmaSecret
|
||||||
|
@show="load_credentials(creds.dav, 'admin/credentials/dav')"
|
||||||
|
@hide="clear_credentials(creds.dav)"
|
||||||
|
>
|
||||||
|
<span class="tag is-danger">user</span>
|
||||||
|
{{ creds.dav.username }}
|
||||||
|
<br />
|
||||||
|
<span class="tag is-danger">pass</span>
|
||||||
|
{{ creds.dav.password }}
|
||||||
|
</BulmaSecret>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Cache-Dauer</dt>
|
||||||
|
<dd>{{ admin_config_model.webdav.cache_ttl }} s</dd>
|
||||||
|
|
||||||
|
<dt>Konfigurationsdatei</dt>
|
||||||
|
<dd>{{ admin_config_model.webdav.config_file }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h3>Sonstige</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Redis</dt>
|
||||||
|
<dd>Host: {{ admin_config_model.redis.host }}</dd>
|
||||||
|
<dd>Port: {{ admin_config_model.redis.port }}</dd>
|
||||||
|
<dd>Datenbank: {{ admin_config_model.redis.db }}</dd>
|
||||||
|
<dd>Protokoll: {{ admin_config_model.redis.protocol }}</dd>
|
||||||
|
|
||||||
|
<dt>UI-Admin</dt>
|
||||||
|
<dd class="is-family-monospace">
|
||||||
|
<BulmaSecret
|
||||||
|
@show="load_credentials(creds.ui, 'admin/credentials/ui')"
|
||||||
|
@hide="clear_credentials(creds.ui)"
|
||||||
|
>
|
||||||
|
<span class="tag is-danger">user</span>
|
||||||
|
{{ creds.ui.username }}
|
||||||
|
<br />
|
||||||
|
<span class="tag is-danger">pass</span>
|
||||||
|
{{ creds.ui.password }}
|
||||||
|
</BulmaSecret>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BulmaDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { API } from "@/lib/api";
|
||||||
|
import type { AdminConfigModel, Credentials, DoorSaved } from "@/lib/model";
|
||||||
|
import { advent22Store } from "@/lib/store";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
import BulmaDrawer from "../bulma/Drawer.vue";
|
||||||
|
import BulmaSecret from "../bulma/Secret.vue";
|
||||||
|
import CountDown from "../CountDown.vue";
|
||||||
|
|
||||||
|
const store = advent22Store();
|
||||||
|
|
||||||
|
const admin_config_model = ref<AdminConfigModel>({
|
||||||
|
solution: {
|
||||||
|
value: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||||
|
whitespace: "KEEP",
|
||||||
|
special_chars: "KEEP",
|
||||||
|
case: "KEEP",
|
||||||
|
clean: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||||
|
},
|
||||||
|
puzzle: {
|
||||||
|
first: "2023-12-01",
|
||||||
|
next: "2023-12-01",
|
||||||
|
last: "2023-12-24",
|
||||||
|
end: "2024-04-01",
|
||||||
|
seed: "",
|
||||||
|
extra_days: [],
|
||||||
|
skip_empty: true,
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
config_file: "lorem ipsum",
|
||||||
|
background: "dolor sit",
|
||||||
|
favicon: "sit amet",
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
size: 500,
|
||||||
|
border: 0,
|
||||||
|
},
|
||||||
|
fonts: [{ file: "consetetur", size: 0 }],
|
||||||
|
redis: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 6379,
|
||||||
|
db: 0,
|
||||||
|
protocol: 3,
|
||||||
|
},
|
||||||
|
webdav: {
|
||||||
|
url: "sadipscing elitr",
|
||||||
|
cache_ttl: 0,
|
||||||
|
config_file: "sed diam nonumy",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const doors = ref<DoorSaved[]>([]);
|
||||||
|
const creds = ref<Record<string, Credentials>>({
|
||||||
|
dav: {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function fmt_puzzle_date(name: keyof AdminConfigModel["puzzle"]): string {
|
||||||
|
const iso_date = admin_config_model.value.puzzle[name];
|
||||||
|
if (!(typeof iso_date === "string")) return "-";
|
||||||
|
|
||||||
|
return DateTime.fromISO(iso_date).toLocaleString(DateTime.DATE_SHORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function on_open(): Promise<void> {
|
||||||
|
const [store_update, new_admin_config_model, new_doors] = await Promise.all([
|
||||||
|
store.update(),
|
||||||
|
API.request<AdminConfigModel>("admin/config_model"),
|
||||||
|
API.request<DoorSaved[]>("admin/doors"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
void store_update; // discard value
|
||||||
|
admin_config_model.value = new_admin_config_model;
|
||||||
|
doors.value = new_doors;
|
||||||
|
|
||||||
|
clear_credentials(creds.value.dav);
|
||||||
|
clear_credentials(creds.value.ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load_credentials(
|
||||||
|
creds: Credentials,
|
||||||
|
endpoint: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const new_creds = await API.request<Credentials>(endpoint);
|
||||||
|
|
||||||
|
creds.username = new_creds.username;
|
||||||
|
creds.password = new_creds.password;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear_credentials(creds: Credentials): void {
|
||||||
|
creds.username = "";
|
||||||
|
creds.password = "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
dd {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
174
ui/src/components/admin/DoorMapEditor.vue
Normal file
174
ui/src/components/admin/DoorMapEditor.vue
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
<template>
|
||||||
|
<BulmaDrawer header="Türchen bearbeiten" :opening="load_doors">
|
||||||
|
<nav class="level is-mobile mb-0" style="overflow-x: auto">
|
||||||
|
<BulmaButton
|
||||||
|
:disabled="current_step === 0"
|
||||||
|
class="level-item is-link"
|
||||||
|
@click="current_step--"
|
||||||
|
:icon="['fas', 'fa-backward']"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BulmaBreadcrumbs
|
||||||
|
:steps="steps"
|
||||||
|
v-model="current_step"
|
||||||
|
class="level-item mb-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BulmaButton
|
||||||
|
:disabled="current_step === 2"
|
||||||
|
class="level-item is-link"
|
||||||
|
@click="current_step++"
|
||||||
|
:icon="['fas', 'fa-forward']"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card-content pb-0">
|
||||||
|
<div v-if="doors.length > 0" class="content">
|
||||||
|
<p>Für diese Tage ist ein Türchen vorhanden:</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span
|
||||||
|
v-for="(door, index) in doors.toSorted((a, b) => a.day - b.day)"
|
||||||
|
:key="`door-${index}`"
|
||||||
|
class="tag is-primary"
|
||||||
|
>
|
||||||
|
{{ door.day }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DoorPlacer v-if="current_step === 0" v-model="doors" />
|
||||||
|
<DoorChooser v-if="current_step === 1" v-model="doors" />
|
||||||
|
<div v-if="current_step === 2" class="card-content">
|
||||||
|
<Calendar :doors="doors" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="card-footer is-flex is-justify-content-space-around">
|
||||||
|
<BulmaButton
|
||||||
|
class="card-footer-item is-danger"
|
||||||
|
@click="on_download"
|
||||||
|
:icon="['fas', 'fa-cloud-arrow-down']"
|
||||||
|
:busy="loading_doors"
|
||||||
|
text="Laden"
|
||||||
|
/>
|
||||||
|
<BulmaButton
|
||||||
|
class="card-footer-item is-warning"
|
||||||
|
@click="on_discard"
|
||||||
|
:icon="['fas', 'fa-trash']"
|
||||||
|
text="Löschen"
|
||||||
|
/>
|
||||||
|
<BulmaButton
|
||||||
|
class="card-footer-item is-success"
|
||||||
|
@click="on_upload"
|
||||||
|
:icon="['fas', 'fa-cloud-arrow-up']"
|
||||||
|
:busy="saving_doors"
|
||||||
|
text="Speichern"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</BulmaDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { API } from "@/lib/api";
|
||||||
|
import { APIError } from "@/lib/api_error";
|
||||||
|
import type { DoorSaved } from "@/lib/model";
|
||||||
|
import { Door } from "@/lib/rects/door";
|
||||||
|
import { toast } from "bulma-toast";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import type { BCStep } from "../bulma/Breadcrumbs.vue";
|
||||||
|
|
||||||
|
import Calendar from "../Calendar.vue";
|
||||||
|
import BulmaBreadcrumbs from "../bulma/Breadcrumbs.vue";
|
||||||
|
import BulmaButton from "../bulma/Button.vue";
|
||||||
|
import BulmaDrawer from "../bulma/Drawer.vue";
|
||||||
|
import DoorChooser from "../editor/DoorChooser.vue";
|
||||||
|
import DoorPlacer from "../editor/DoorPlacer.vue";
|
||||||
|
|
||||||
|
const steps: BCStep[] = [
|
||||||
|
{ label: "Platzieren", icon: ["fas", "fa-crosshairs"] },
|
||||||
|
{ label: "Ordnen", icon: ["fas", "fa-list-ol"] },
|
||||||
|
{ label: "Vorschau", icon: ["fas", "fa-magnifying-glass"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const doors = ref<Door[]>([]);
|
||||||
|
const current_step = ref(0);
|
||||||
|
const loading_doors = ref(false);
|
||||||
|
const saving_doors = ref(false);
|
||||||
|
|
||||||
|
async function load_doors(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await API.request<DoorSaved[]>("admin/doors");
|
||||||
|
|
||||||
|
doors.value.length = 0;
|
||||||
|
for (const value of data) {
|
||||||
|
doors.value.push(Door.load(value));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
APIError.alert(error);
|
||||||
|
throw null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save_doors(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data: DoorSaved[] = [];
|
||||||
|
|
||||||
|
for (const door of doors.value) {
|
||||||
|
data.push(door.save());
|
||||||
|
}
|
||||||
|
|
||||||
|
await API.request<void>({
|
||||||
|
endpoint: "admin/doors",
|
||||||
|
method: "PUT",
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
APIError.alert(error);
|
||||||
|
throw null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function on_download(): Promise<void> {
|
||||||
|
if (confirm("Aktuelle Änderungen verwerfen und Status vom Server laden?")) {
|
||||||
|
loading_doors.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
load_doors();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
message: "Erfolgreich!",
|
||||||
|
type: "is-success",
|
||||||
|
duration: 2e3,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading_doors.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function on_discard(): void {
|
||||||
|
if (confirm("Alle Türchen löschen? (nur lokal)")) {
|
||||||
|
// empty `doors` array
|
||||||
|
doors.value.length = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function on_upload(): Promise<void> {
|
||||||
|
if (confirm("Aktuelle Änderungen an den Server schicken?")) {
|
||||||
|
saving_doors.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
save_doors();
|
||||||
|
load_doors();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
message: "Erfolgreich!",
|
||||||
|
type: "is-success",
|
||||||
|
duration: 2e3,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
saving_doors.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
33
ui/src/components/bulma/Breadcrumbs.vue
Normal file
33
ui/src/components/bulma/Breadcrumbs.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<!-- eslint-disable vue/multi-word-component-names -->
|
||||||
|
<template>
|
||||||
|
<nav class="breadcrumb has-succeeds-separator">
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="(step, index) in steps"
|
||||||
|
:key="index"
|
||||||
|
:class="model === index ? 'is-active' : ''"
|
||||||
|
@click.left="model = index"
|
||||||
|
>
|
||||||
|
<a :class="model === index ? 'has-text-primary' : ''">
|
||||||
|
<span class="icon is-small">
|
||||||
|
<FontAwesomeIcon :icon="step.icon" />
|
||||||
|
</span>
|
||||||
|
<span>{{ step.label }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
export interface BCStep {
|
||||||
|
label: string;
|
||||||
|
icon: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = defineModel<number>({ required: true });
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
steps: BCStep[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
28
ui/src/components/bulma/Button.vue
Normal file
28
ui/src/components/bulma/Button.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<!-- eslint-disable vue/multi-word-component-names -->
|
||||||
|
<template>
|
||||||
|
<button class="button">
|
||||||
|
<slot name="default">
|
||||||
|
<span v-if="icon !== undefined" class="icon">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
v-if="icon !== undefined"
|
||||||
|
:icon="icon"
|
||||||
|
:beat-fade="busy"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</slot>
|
||||||
|
<span v-if="text !== undefined">{{ text }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
icon?: string | string[];
|
||||||
|
text?: string;
|
||||||
|
busy?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
busy: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
93
ui/src/components/bulma/Drawer.vue
Normal file
93
ui/src/components/bulma/Drawer.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<!-- eslint-disable vue/multi-word-component-names -->
|
||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<header class="card-header is-unselectable" style="cursor: pointer">
|
||||||
|
<p class="card-header-title" @click="toggle">{{ header }}</p>
|
||||||
|
|
||||||
|
<p v-if="refreshable && is_open" class="card-header-icon px-0">
|
||||||
|
<BulmaButton class="is-small is-primary" @click="load">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
:icon="['fas', 'arrows-rotate']"
|
||||||
|
:spin="state === 'loading'"
|
||||||
|
/>
|
||||||
|
</BulmaButton>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button class="card-header-icon" @click="toggle">
|
||||||
|
<span class="icon">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
:icon="['fas', is_open ? 'angle-down' : 'angle-right']"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<slot v-if="state === 'loading'" name="loading">
|
||||||
|
<div class="card-content">
|
||||||
|
<progress class="progress is-primary" />
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<slot v-else-if="state === 'err'" name="error">
|
||||||
|
<div class="card-content has-text-danger has-text-centered">
|
||||||
|
<span class="icon is-large">
|
||||||
|
<FontAwesomeIcon :icon="['fas', 'ban']" size="3x" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<slot v-else-if="state === 'ok'" name="default" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
import BulmaButton from "./Button.vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
header: string;
|
||||||
|
opening?: () => Promise<void>;
|
||||||
|
refreshable?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ opening: async () => {}, refreshable: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = ref<"closed" | "loading" | "ok" | "err">("closed");
|
||||||
|
const is_open = computed(() => state.value !== "closed");
|
||||||
|
|
||||||
|
async function toggle(): Promise<void> {
|
||||||
|
switch (state.value) {
|
||||||
|
case "closed":
|
||||||
|
// start opening when closed
|
||||||
|
await load();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "loading":
|
||||||
|
// don't toggle when loading
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
state.value = "closed";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
state.value = "loading";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await props.opening();
|
||||||
|
state.value = "ok";
|
||||||
|
} catch {
|
||||||
|
state.value = "err";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
div.card:not(:last-child) {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
52
ui/src/components/bulma/Secret.vue
Normal file
52
ui/src/components/bulma/Secret.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<!-- eslint-disable vue/multi-word-component-names -->
|
||||||
|
<template>
|
||||||
|
<slot v-if="state === 'visible'" name="default" />
|
||||||
|
<span v-else>***</span>
|
||||||
|
<BulmaButton
|
||||||
|
:class="`is-small is-${record.color} ml-2`"
|
||||||
|
:icon="['fas', record.icon]"
|
||||||
|
:busy="state === 'pending'"
|
||||||
|
@click="on_click"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
import BulmaButton from "./Button.vue";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "show"): void;
|
||||||
|
(event: "hide"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type State = "hidden" | "pending" | "visible";
|
||||||
|
const state = ref<State>("hidden");
|
||||||
|
|
||||||
|
const state_map: Record<State, { color: string; icon: string; next: State }> = {
|
||||||
|
hidden: { color: "primary", icon: "eye-slash", next: "pending" },
|
||||||
|
pending: { color: "warning", icon: "eye-slash", next: "visible" },
|
||||||
|
visible: { color: "danger", icon: "eye", next: "hidden" },
|
||||||
|
} as const;
|
||||||
|
const record = computed(() => state_map[state.value] ?? state_map.hidden);
|
||||||
|
|
||||||
|
let pending_timeout: number | undefined;
|
||||||
|
|
||||||
|
function on_click(): void {
|
||||||
|
state.value = record.value.next;
|
||||||
|
|
||||||
|
if (state.value === "hidden") {
|
||||||
|
emit("hide");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value === "pending") {
|
||||||
|
pending_timeout = window.setTimeout(() => (state.value = "hidden"), 2500);
|
||||||
|
} else {
|
||||||
|
window.clearTimeout(pending_timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value === "visible") {
|
||||||
|
emit("show");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
47
ui/src/components/bulma/Toast.vue
Normal file
47
ui/src/components/bulma/Toast.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<!-- eslint-disable vue/multi-word-component-names -->
|
||||||
|
<template>
|
||||||
|
<div style="display: none">
|
||||||
|
<div v-bind="$attrs" ref="message">
|
||||||
|
<slot name="default" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type Options as ToastOptions, toast } from "bulma-toast";
|
||||||
|
import { onMounted, useTemplateRef } from "vue";
|
||||||
|
|
||||||
|
export type HBulmaToast = {
|
||||||
|
show(options: ToastOptions): void;
|
||||||
|
hide(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "handle", handle: HBulmaToast): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const message_div = useTemplateRef("message");
|
||||||
|
|
||||||
|
onMounted(() =>
|
||||||
|
emit("handle", {
|
||||||
|
show(options: ToastOptions = {}): void {
|
||||||
|
if (message_div.value === null) return;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
...options,
|
||||||
|
single: true,
|
||||||
|
message: message_div.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
hide(): void {
|
||||||
|
// using "toast" detaches "message" from the invisible "div"
|
||||||
|
// => toast_div is not part of this component!
|
||||||
|
const toast_div = message_div.value?.parentElement;
|
||||||
|
const delete_button = toast_div?.querySelector("button.delete");
|
||||||
|
if (!(delete_button instanceof HTMLButtonElement)) return;
|
||||||
|
|
||||||
|
delete_button.click();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
34
ui/src/components/calendar/CalendarDoor.vue
Normal file
34
ui/src/components/calendar/CalendarDoor.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<template>
|
||||||
|
<SVGRect
|
||||||
|
variant="primary"
|
||||||
|
:visible="store.is_touch_device || force_visible"
|
||||||
|
:rectangle="door.position"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="has-text-danger"
|
||||||
|
style="text-shadow: 0 0 10px white, 0 0 20px white"
|
||||||
|
>
|
||||||
|
{{ door.day }}
|
||||||
|
</div>
|
||||||
|
</SVGRect>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Door } from "@/lib/rects/door";
|
||||||
|
import { advent22Store } from "@/lib/store";
|
||||||
|
|
||||||
|
import type { VueLike } from "@/lib/helpers";
|
||||||
|
import SVGRect from "./SVGRect.vue";
|
||||||
|
|
||||||
|
const store = advent22Store();
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
door: VueLike<Door>;
|
||||||
|
force_visible?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
force_visible: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
79
ui/src/components/calendar/SVGRect.vue
Normal file
79
ui/src/components/calendar/SVGRect.vue
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<template>
|
||||||
|
<foreignObject
|
||||||
|
:x="Math.round(aspect_ratio * rectangle.left)"
|
||||||
|
:y="rectangle.top"
|
||||||
|
:width="Math.round(aspect_ratio * rectangle.width)"
|
||||||
|
:height="rectangle.height"
|
||||||
|
:style="`transform: scaleX(${1 / aspect_ratio})`"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
xmlns="http://www.w3.org/1999/xhtml"
|
||||||
|
:class="`px-2 is-flex is-align-items-center is-justify-content-center is-size-2 has-text-weight-bold ${variant} ${
|
||||||
|
visible ? 'visible' : ''
|
||||||
|
}`"
|
||||||
|
style="height: inherit"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<slot name="default" />
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type VueLike, unwrap_loading } from "@/lib/helpers";
|
||||||
|
import { Rectangle } from "@/lib/rects/rectangle";
|
||||||
|
import { advent22Store } from "@/lib/store";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const store = advent22Store();
|
||||||
|
|
||||||
|
type BulmaVariant =
|
||||||
|
| "primary"
|
||||||
|
| "link"
|
||||||
|
| "info"
|
||||||
|
| "success"
|
||||||
|
| "warning"
|
||||||
|
| "danger";
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
variant: BulmaVariant;
|
||||||
|
visible?: boolean;
|
||||||
|
rectangle: VueLike<Rectangle>;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const aspect_ratio = computed(() => {
|
||||||
|
try {
|
||||||
|
return unwrap_loading(store.background_image).aspect_ratio;
|
||||||
|
} catch {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/bulma-scheme" as scheme;
|
||||||
|
|
||||||
|
foreignObject > div {
|
||||||
|
&:not(.visible, :hover):deep() > * {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.visible,
|
||||||
|
&:hover {
|
||||||
|
border-width: 2px;
|
||||||
|
border-style: solid;
|
||||||
|
|
||||||
|
@each $name, $color in scheme.$colors {
|
||||||
|
&.#{$name} {
|
||||||
|
background-color: rgba($color, 0.3);
|
||||||
|
border-color: rgba($color, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
63
ui/src/components/calendar/ThouCanvas.vue
Normal file
63
ui/src/components/calendar/ThouCanvas.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
version="1.1"
|
||||||
|
viewBox="0 0 1000 1000"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
@contextmenu.prevent
|
||||||
|
@mousedown="transform_mouse_event"
|
||||||
|
@mousemove="transform_mouse_event"
|
||||||
|
@mouseup="transform_mouse_event"
|
||||||
|
@click="transform_mouse_event"
|
||||||
|
@dblclick="transform_mouse_event"
|
||||||
|
>
|
||||||
|
<slot name="default" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Vector2D } from "@/lib/rects/vector2d";
|
||||||
|
|
||||||
|
function get_event_thous(event: MouseEvent): Vector2D {
|
||||||
|
if (!(event.currentTarget instanceof SVGSVGElement)) {
|
||||||
|
return new Vector2D();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Vector2D(
|
||||||
|
Math.round((event.offsetX / event.currentTarget.clientWidth) * 1000),
|
||||||
|
Math.round((event.offsetY / event.currentTarget.clientHeight) * 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TCEventType = "mousedown" | "mousemove" | "mouseup" | "click" | "dblclick";
|
||||||
|
|
||||||
|
const is_tceventtype = (t: unknown): t is TCEventType =>
|
||||||
|
t === "mousedown" ||
|
||||||
|
t === "mousemove" ||
|
||||||
|
t === "mouseup" ||
|
||||||
|
t === "click" ||
|
||||||
|
t === "dblclick";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: TCEventType, e: MouseEvent, point: Vector2D): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function transform_mouse_event(event: MouseEvent): void {
|
||||||
|
if (!is_tceventtype(event.type)) return;
|
||||||
|
|
||||||
|
emit(event.type, event, get_event_thous(event));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
svg {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
z-index: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
119
ui/src/components/editor/DoorCanvas.vue
Normal file
119
ui/src/components/editor/DoorCanvas.vue
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<template>
|
||||||
|
<ThouCanvas
|
||||||
|
@mousedown.left="draw_start"
|
||||||
|
@mouseup.left="draw_finish"
|
||||||
|
@mousedown.right="drag_start"
|
||||||
|
@mouseup.right="drag_finish"
|
||||||
|
@mousemove="on_mousemove"
|
||||||
|
@click.middle="remove_rect"
|
||||||
|
@dblclick.left="remove_rect"
|
||||||
|
>
|
||||||
|
<CalendarDoor
|
||||||
|
v-for="(door, index) in model"
|
||||||
|
:key="`door-${index}`"
|
||||||
|
:door="door"
|
||||||
|
force_visible
|
||||||
|
/>
|
||||||
|
<SVGRect
|
||||||
|
v-if="preview_visible"
|
||||||
|
variant="success"
|
||||||
|
:rectangle="preview"
|
||||||
|
visible
|
||||||
|
/>
|
||||||
|
</ThouCanvas>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Door } from "@/lib/rects/door";
|
||||||
|
import { Rectangle } from "@/lib/rects/rectangle";
|
||||||
|
import { Vector2D } from "@/lib/rects/vector2d";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
import type { VueLike } from "@/lib/helpers";
|
||||||
|
import CalendarDoor from "../calendar/CalendarDoor.vue";
|
||||||
|
import SVGRect from "../calendar/SVGRect.vue";
|
||||||
|
import ThouCanvas from "../calendar/ThouCanvas.vue";
|
||||||
|
|
||||||
|
type CanvasState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "drawing" }
|
||||||
|
| { kind: "dragging"; door: VueLike<Door>; origin: Vector2D };
|
||||||
|
|
||||||
|
const model = defineModel<VueLike<Door>[]>({ required: true });
|
||||||
|
|
||||||
|
const MIN_RECT_AREA = 300;
|
||||||
|
const state = ref<CanvasState>({ kind: "idle" });
|
||||||
|
const preview = ref(new Rectangle());
|
||||||
|
|
||||||
|
const preview_visible = computed(() => state.value.kind !== "idle");
|
||||||
|
|
||||||
|
function pop_door(point: Vector2D): VueLike<Door> | undefined {
|
||||||
|
const idx = model.value.findIndex((rect) => rect.position.contains(point));
|
||||||
|
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
return model.value.splice(idx, 1)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw_start(event: MouseEvent, point: Vector2D): void {
|
||||||
|
if (preview_visible.value) return;
|
||||||
|
|
||||||
|
preview.value = new Rectangle(point, point);
|
||||||
|
state.value = { kind: "drawing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw_finish(): void {
|
||||||
|
if (state.value.kind !== "drawing") return;
|
||||||
|
|
||||||
|
if (preview.value.area >= MIN_RECT_AREA) {
|
||||||
|
model.value.push(new Door(preview.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value = { kind: "idle" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag_start(event: MouseEvent, point: Vector2D): void {
|
||||||
|
if (preview_visible.value) return;
|
||||||
|
|
||||||
|
const drag_door = pop_door(point);
|
||||||
|
|
||||||
|
if (drag_door === undefined) return;
|
||||||
|
|
||||||
|
preview.value = drag_door.position;
|
||||||
|
|
||||||
|
state.value = { kind: "dragging", door: drag_door, origin: point };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag_finish(): void {
|
||||||
|
if (state.value.kind !== "dragging") return;
|
||||||
|
|
||||||
|
model.value.push(new Door(preview.value, state.value.door.day));
|
||||||
|
|
||||||
|
state.value = { kind: "idle" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function on_mousemove(event: MouseEvent, point: Vector2D): void {
|
||||||
|
if (state.value.kind === "drawing") {
|
||||||
|
preview.value = preview.value.update(undefined, point);
|
||||||
|
} else if (state.value.kind === "dragging") {
|
||||||
|
const movement = point.minus(state.value.origin);
|
||||||
|
preview.value = state.value.door.position.move(movement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove_rect(event: MouseEvent, point: Vector2D): void {
|
||||||
|
if (preview_visible.value) return;
|
||||||
|
|
||||||
|
pop_door(point);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
svg {
|
||||||
|
cursor: crosshair;
|
||||||
|
|
||||||
|
* {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
ui/src/components/editor/DoorChooser.vue
Normal file
36
ui/src/components/editor/DoorChooser.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<template>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="content is-small">
|
||||||
|
<h3>Steuerung</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Linksklick: Türchen bearbeiten</li>
|
||||||
|
<li>Tastatur: Tag eingeben</li>
|
||||||
|
<li>[Enter]: Tag speichern</li>
|
||||||
|
<li>[Esc]: Eingabe Abbrechen</li>
|
||||||
|
<li>[Entf]: Tag entfernen</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<figure class="image is-unselectable">
|
||||||
|
<img :src="unwrap_loading(store.background_image).data_url" />
|
||||||
|
<ThouCanvas>
|
||||||
|
<PreviewDoor
|
||||||
|
v-for="(_, index) in model"
|
||||||
|
:key="`door-${index}`"
|
||||||
|
v-model="model[index]"
|
||||||
|
/>
|
||||||
|
</ThouCanvas>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type VueLike, unwrap_loading } from "@/lib/helpers";
|
||||||
|
import { Door } from "@/lib/rects/door";
|
||||||
|
import { advent22Store } from "@/lib/store";
|
||||||
|
|
||||||
|
import ThouCanvas from "../calendar/ThouCanvas.vue";
|
||||||
|
import PreviewDoor from "./PreviewDoor.vue";
|
||||||
|
|
||||||
|
const model = defineModel<VueLike<Door>[]>({ required: true });
|
||||||
|
const store = advent22Store();
|
||||||
|
</script>
|
||||||
27
ui/src/components/editor/DoorPlacer.vue
Normal file
27
ui/src/components/editor/DoorPlacer.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<template>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="content is-small">
|
||||||
|
<h3>Steuerung</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Linksklick + Ziehen: Neues Türchen erstellen</li>
|
||||||
|
<li>Rechtsklick + Ziehen: Türchen verschieben</li>
|
||||||
|
<li>Doppel- oder Mittelklick: Türchen löschen</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<figure class="image is-unselectable">
|
||||||
|
<img :src="unwrap_loading(store.background_image).data_url" />
|
||||||
|
<DoorCanvas v-model="model" />
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type VueLike, unwrap_loading } from "@/lib/helpers";
|
||||||
|
import { Door } from "@/lib/rects/door";
|
||||||
|
import { advent22Store } from "@/lib/store";
|
||||||
|
|
||||||
|
import DoorCanvas from "./DoorCanvas.vue";
|
||||||
|
|
||||||
|
const model = defineModel<VueLike<Door>[]>({ required: true });
|
||||||
|
const store = advent22Store();
|
||||||
|
</script>
|
||||||
71
ui/src/components/editor/PreviewDoor.vue
Normal file
71
ui/src/components/editor/PreviewDoor.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<template>
|
||||||
|
<SVGRect
|
||||||
|
style="cursor: text"
|
||||||
|
:rectangle="model.position"
|
||||||
|
:variant="editing ? 'success' : 'primary'"
|
||||||
|
@click.left.stop="on_click"
|
||||||
|
visible
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-if="editing"
|
||||||
|
v-model="day_str"
|
||||||
|
ref="day_input"
|
||||||
|
class="input is-large"
|
||||||
|
type="number"
|
||||||
|
:min="Door.MIN_DAY"
|
||||||
|
placeholder="Tag"
|
||||||
|
@keydown="on_keydown"
|
||||||
|
/>
|
||||||
|
<div v-else class="has-text-danger">
|
||||||
|
{{ model.day > 0 ? model.day : "*" }}
|
||||||
|
</div>
|
||||||
|
</SVGRect>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Door } from "@/lib/rects/door";
|
||||||
|
import { ref, useTemplateRef } from "vue";
|
||||||
|
|
||||||
|
import { type VueLike, unwrap_vuelike, wait_for } from "@/lib/helpers";
|
||||||
|
import SVGRect from "../calendar/SVGRect.vue";
|
||||||
|
|
||||||
|
const model = defineModel<VueLike<Door>>({ required: true });
|
||||||
|
const day_input = useTemplateRef("day_input");
|
||||||
|
|
||||||
|
const day_str = ref("");
|
||||||
|
const editing = ref(false);
|
||||||
|
|
||||||
|
function toggle_editing(): void {
|
||||||
|
day_str.value = String(model.value.day);
|
||||||
|
editing.value = !editing.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function on_click(event: MouseEvent): void {
|
||||||
|
if (!(event.target instanceof HTMLDivElement)) return;
|
||||||
|
|
||||||
|
if (editing.value) {
|
||||||
|
unwrap_vuelike(model.value).day = day_str.value;
|
||||||
|
} else {
|
||||||
|
wait_for(
|
||||||
|
() => day_input.value !== null,
|
||||||
|
() => day_input.value!.select(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle_editing();
|
||||||
|
}
|
||||||
|
|
||||||
|
function on_keydown(event: KeyboardEvent): void {
|
||||||
|
if (!editing.value) return;
|
||||||
|
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
unwrap_vuelike(model.value).day = day_str.value;
|
||||||
|
toggle_editing();
|
||||||
|
} else if (event.key === "Delete") {
|
||||||
|
model.value.day = -1;
|
||||||
|
toggle_editing();
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
toggle_editing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
6
ui/src/d.ts/shims-vue.d.ts
vendored
Normal file
6
ui/src/d.ts/shims-vue.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
92
ui/src/lib/api.ts
Normal file
92
ui/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import type {
|
||||||
|
AxiosBasicCredentials,
|
||||||
|
AxiosRequestConfig,
|
||||||
|
Method,
|
||||||
|
RawAxiosRequestHeaders,
|
||||||
|
} from "axios";
|
||||||
|
import axios from "axios";
|
||||||
|
import { APIError } from "./api_error";
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
endpoint: string;
|
||||||
|
method?: Method;
|
||||||
|
data?: unknown;
|
||||||
|
headers?: RawAxiosRequestHeaders;
|
||||||
|
config?: AxiosRequestConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class API {
|
||||||
|
private static get api_baseurl(): string {
|
||||||
|
// in production mode, return "proto://hostname/api"
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
return `${window.location.protocol}//${window.location.host}/api`;
|
||||||
|
} else if (process.env.NODE_ENV !== "development") {
|
||||||
|
// not in prouction or development mode
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn("Unexpected NODE_ENV value: ", process.env.NODE_ENV);
|
||||||
|
}
|
||||||
|
|
||||||
|
// in development mode, return "proto://hostname:8000/api"
|
||||||
|
return `${window.location.protocol}//${window.location.hostname}:8000/api`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly axios = axios.create({
|
||||||
|
timeout: 10e3,
|
||||||
|
baseURL: this.api_baseurl,
|
||||||
|
});
|
||||||
|
|
||||||
|
private static readonly creds_key = "advent22/credentials";
|
||||||
|
|
||||||
|
public static set creds(value: AxiosBasicCredentials | null) {
|
||||||
|
if (value === null) {
|
||||||
|
localStorage.removeItem(this.creds_key);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(this.creds_key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get creds(): AxiosBasicCredentials {
|
||||||
|
const stored_auth = JSON.parse(localStorage.getItem(this.creds_key) ?? "null");
|
||||||
|
if (
|
||||||
|
stored_auth !== null &&
|
||||||
|
Object.hasOwn(stored_auth, "username") &&
|
||||||
|
Object.hasOwn(stored_auth, "password")
|
||||||
|
) {
|
||||||
|
return stored_auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { username: "", password: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static get_axios_config({
|
||||||
|
endpoint,
|
||||||
|
method = "GET",
|
||||||
|
data,
|
||||||
|
headers = {},
|
||||||
|
config = {},
|
||||||
|
}: Params): AxiosRequestConfig {
|
||||||
|
return {
|
||||||
|
url: endpoint,
|
||||||
|
method: method,
|
||||||
|
data: data,
|
||||||
|
auth: this.creds,
|
||||||
|
headers: headers,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async request<T = string>(p: Params): Promise<T>;
|
||||||
|
public static async request<T = string>(p: string): Promise<T>;
|
||||||
|
public static async request<T = string>(p: Params | string): Promise<T> {
|
||||||
|
if (typeof p === "string") p = { endpoint: p };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.axios.request<T>(this.get_axios_config(p));
|
||||||
|
return response.data;
|
||||||
|
} catch (reason) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`Failed to query ${p.endpoint}: ${reason}`);
|
||||||
|
throw new APIError(reason, p.endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
ui/src/lib/api_error.ts
Normal file
75
ui/src/lib/api_error.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { toast } from "bulma-toast";
|
||||||
|
|
||||||
|
export class APIError extends Error {
|
||||||
|
axios_error?: AxiosError;
|
||||||
|
|
||||||
|
constructor(reason: unknown, endpoint: string) {
|
||||||
|
super(endpoint); // sets this.message to the endpoint
|
||||||
|
Object.setPrototypeOf(this, APIError.prototype);
|
||||||
|
|
||||||
|
if (reason instanceof AxiosError) {
|
||||||
|
this.axios_error = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public format(): string {
|
||||||
|
let msg =
|
||||||
|
"Unbekannter Fehler, bitte wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
|
||||||
|
let code = "U";
|
||||||
|
const result = () => `${msg} (Fehlercode: ${code}/${this.message})`;
|
||||||
|
|
||||||
|
if (this.axios_error === undefined) return result();
|
||||||
|
|
||||||
|
switch (this.axios_error.code) {
|
||||||
|
case "ECONNABORTED":
|
||||||
|
// API unerreichbar
|
||||||
|
msg =
|
||||||
|
"API antwortet nicht, bitte später wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
|
||||||
|
code = "D";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ERR_NETWORK":
|
||||||
|
// Netzwerk nicht verbunden
|
||||||
|
msg = "Sieht aus, als sei deine Netzwerkverbindung gestört.";
|
||||||
|
code = "N";
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (this.axios_error.response === undefined) return result();
|
||||||
|
|
||||||
|
switch (this.axios_error.response.status) {
|
||||||
|
case 401:
|
||||||
|
// UNAUTHORIZED
|
||||||
|
msg = "Netter Versuch :)";
|
||||||
|
code = "A";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 422:
|
||||||
|
// UNPROCESSABLE ENTITY
|
||||||
|
msg = "Funktion ist kaputt, bitte Admin benachrichtigen!";
|
||||||
|
code = "I";
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// HTTP
|
||||||
|
code = `H${this.axios_error.response.status}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result();
|
||||||
|
}
|
||||||
|
|
||||||
|
public alert(): void {
|
||||||
|
toast({
|
||||||
|
message: this.format(),
|
||||||
|
type: "is-danger",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static alert(error: unknown): void {
|
||||||
|
new APIError(error, "").alert();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
ui/src/lib/fontawesome.ts
Normal file
14
ui/src/lib/fontawesome.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/* import font awesome icon component */
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
|
|
||||||
|
/* import the fontawesome core */
|
||||||
|
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||||
|
|
||||||
|
/* import specific icons */
|
||||||
|
// import { fab } from "@fortawesome/free-brands-svg-icons";
|
||||||
|
import { fas } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
/* add icons to the library */
|
||||||
|
library.add(fas);
|
||||||
|
|
||||||
|
export default FontAwesomeIcon;
|
||||||
52
ui/src/lib/helpers.ts
Normal file
52
ui/src/lib/helpers.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { nextTick, type UnwrapRef } from "vue";
|
||||||
|
import { APIError } from "./api_error";
|
||||||
|
|
||||||
|
export function objForEach<T>(
|
||||||
|
obj: T,
|
||||||
|
f: (k: keyof T, v: T[keyof T]) => void,
|
||||||
|
): void {
|
||||||
|
for (const k in obj) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(obj, k)) {
|
||||||
|
f(k, obj[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VueLike<T> = T | UnwrapRef<T>;
|
||||||
|
|
||||||
|
export function unwrap_vuelike<T>(value: VueLike<T>): T {
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Loading<T> = T | "loading" | "error";
|
||||||
|
|
||||||
|
export function unwrap_loading<T>(o: Loading<T>): T {
|
||||||
|
if (o === "loading" || o === "error") throw null;
|
||||||
|
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wait_for(condition: () => boolean, action: () => void): void {
|
||||||
|
const enqueue_action = () => {
|
||||||
|
if (!condition()) {
|
||||||
|
nextTick(enqueue_action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
enqueue_action();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handle_error(error: unknown): void {
|
||||||
|
if (error instanceof APIError) {
|
||||||
|
error.alert();
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function name_door(day: number): string {
|
||||||
|
return `Türchen ${day}`;
|
||||||
|
}
|
||||||
70
ui/src/lib/model.ts
Normal file
70
ui/src/lib/model.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
export interface AdminConfigModel {
|
||||||
|
solution: {
|
||||||
|
value: string;
|
||||||
|
whitespace: string;
|
||||||
|
special_chars: string;
|
||||||
|
case: string;
|
||||||
|
clean: string;
|
||||||
|
};
|
||||||
|
puzzle: {
|
||||||
|
first: string;
|
||||||
|
next: string | null;
|
||||||
|
last: string;
|
||||||
|
end: string;
|
||||||
|
seed: string;
|
||||||
|
extra_days: number[];
|
||||||
|
skip_empty: boolean;
|
||||||
|
};
|
||||||
|
calendar: {
|
||||||
|
config_file: string;
|
||||||
|
background: string;
|
||||||
|
favicon: string;
|
||||||
|
};
|
||||||
|
image: {
|
||||||
|
size: number;
|
||||||
|
border: number;
|
||||||
|
};
|
||||||
|
fonts: { file: string; size: number }[];
|
||||||
|
redis: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
db: number;
|
||||||
|
protocol: number;
|
||||||
|
};
|
||||||
|
webdav: {
|
||||||
|
url: string;
|
||||||
|
cache_ttl: number;
|
||||||
|
config_file: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteConfigModel {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
content: string;
|
||||||
|
footer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumStrDict {
|
||||||
|
[key: number]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DoorSaved {
|
||||||
|
day: number;
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageData {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
aspect_ratio: number;
|
||||||
|
data_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Credentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
54
ui/src/lib/rects/door.ts
Normal file
54
ui/src/lib/rects/door.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { type VueLike, unwrap_vuelike } from "../helpers";
|
||||||
|
import type { DoorSaved } from "../model";
|
||||||
|
import { Rectangle } from "./rectangle";
|
||||||
|
import { Vector2D } from "./vector2d";
|
||||||
|
|
||||||
|
export class Door {
|
||||||
|
public static readonly MIN_DAY = 1;
|
||||||
|
|
||||||
|
private _day = Door.MIN_DAY;
|
||||||
|
public position: Rectangle;
|
||||||
|
|
||||||
|
constructor(position: VueLike<Rectangle>);
|
||||||
|
constructor(position: VueLike<Rectangle>, day: number);
|
||||||
|
constructor(position: VueLike<Rectangle>, day = Door.MIN_DAY) {
|
||||||
|
this.day = day;
|
||||||
|
this.position = unwrap_vuelike(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get day(): number {
|
||||||
|
return this._day;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set day(value: number | string) {
|
||||||
|
// integer coercion
|
||||||
|
let day = Number(value);
|
||||||
|
|
||||||
|
day =
|
||||||
|
!Number.isNaN(day) && Number.isFinite(day)
|
||||||
|
? Math.trunc(day)
|
||||||
|
: Door.MIN_DAY;
|
||||||
|
|
||||||
|
this._day = Math.max(day, Door.MIN_DAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static load(serialized: DoorSaved): Door {
|
||||||
|
return new Door(
|
||||||
|
new Rectangle(
|
||||||
|
new Vector2D(serialized.x1, serialized.y1),
|
||||||
|
new Vector2D(serialized.x2, serialized.y2),
|
||||||
|
),
|
||||||
|
serialized.day,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public save(): DoorSaved {
|
||||||
|
return {
|
||||||
|
day: this.day,
|
||||||
|
x1: this.position.origin.x,
|
||||||
|
y1: this.position.origin.y,
|
||||||
|
x2: this.position.corner.x,
|
||||||
|
y2: this.position.corner.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
79
ui/src/lib/rects/rectangle.ts
Normal file
79
ui/src/lib/rects/rectangle.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { Vector2D } from "./vector2d";
|
||||||
|
|
||||||
|
export class Rectangle {
|
||||||
|
private readonly corner_1: Vector2D;
|
||||||
|
private readonly corner_2: Vector2D;
|
||||||
|
|
||||||
|
constructor();
|
||||||
|
constructor(corner_1: Vector2D, corner_2: Vector2D);
|
||||||
|
constructor(corner_1 = new Vector2D(), corner_2 = new Vector2D()) {
|
||||||
|
this.corner_1 = corner_1;
|
||||||
|
this.corner_2 = corner_2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get origin(): Vector2D {
|
||||||
|
return new Vector2D(
|
||||||
|
Math.min(this.corner_1.x, this.corner_2.x),
|
||||||
|
Math.min(this.corner_1.y, this.corner_2.y),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get left(): number {
|
||||||
|
return this.origin.x;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get top(): number {
|
||||||
|
return this.origin.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get corner(): Vector2D {
|
||||||
|
return new Vector2D(
|
||||||
|
Math.max(this.corner_1.x, this.corner_2.x),
|
||||||
|
Math.max(this.corner_1.y, this.corner_2.y),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get size(): Vector2D {
|
||||||
|
return this.corner.minus(this.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get width(): number {
|
||||||
|
return this.size.x;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get height(): number {
|
||||||
|
return this.size.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get middle(): Vector2D {
|
||||||
|
return this.origin.plus(this.size.scale(0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
public get area(): number {
|
||||||
|
return this.width * this.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
public equals(other: Rectangle): boolean {
|
||||||
|
return this.origin.equals(other.origin) && this.corner.equals(other.corner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public contains(point: Vector2D): boolean {
|
||||||
|
return (
|
||||||
|
point.x >= this.origin.x &&
|
||||||
|
point.y >= this.origin.y &&
|
||||||
|
point.x <= this.corner.x &&
|
||||||
|
point.y <= this.corner.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(corner_1?: Vector2D, corner_2?: Vector2D): Rectangle {
|
||||||
|
return new Rectangle(corner_1 ?? this.corner_1, corner_2 ?? this.corner_2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public move(vector: Vector2D): Rectangle {
|
||||||
|
return new Rectangle(
|
||||||
|
this.corner_1.plus(vector),
|
||||||
|
this.corner_2.plus(vector),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
ui/src/lib/rects/vector2d.ts
Normal file
27
ui/src/lib/rects/vector2d.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
export class Vector2D {
|
||||||
|
public readonly x: number;
|
||||||
|
public readonly y: number;
|
||||||
|
|
||||||
|
constructor();
|
||||||
|
constructor(x: number, y: number);
|
||||||
|
constructor(x = 0, y = 0) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public plus(other: Vector2D): Vector2D {
|
||||||
|
return new Vector2D(this.x + other.x, this.y + other.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
public minus(other: Vector2D): Vector2D {
|
||||||
|
return new Vector2D(this.x - other.x, this.y - other.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
public scale(other: number): Vector2D {
|
||||||
|
return new Vector2D(this.x * other, this.y * other);
|
||||||
|
}
|
||||||
|
|
||||||
|
public equals(other: Vector2D): boolean {
|
||||||
|
return this.x === other.x && this.y === other.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
ui/src/lib/store.ts
Normal file
131
ui/src/lib/store.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { acceptHMRUpdate, defineStore } from "pinia";
|
||||||
|
import { API } from "./api";
|
||||||
|
import type { Loading } from "./helpers";
|
||||||
|
import type { Credentials, DoorSaved, ImageData, SiteConfigModel } from "./model";
|
||||||
|
import { Door } from "./rects/door";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Navigator {
|
||||||
|
readonly msMaxTouchPoints: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
on_initialized: (() => void)[] | null;
|
||||||
|
is_touch_device: boolean;
|
||||||
|
is_admin: boolean;
|
||||||
|
site_config: SiteConfigModel;
|
||||||
|
background_image: Loading<ImageData>;
|
||||||
|
user_doors: Door[];
|
||||||
|
next_door_target: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const advent22Store = defineStore("advent22", {
|
||||||
|
state: (): State => ({
|
||||||
|
on_initialized: [],
|
||||||
|
is_touch_device:
|
||||||
|
window.matchMedia("(any-hover: none)").matches ||
|
||||||
|
"ontouchstart" in window ||
|
||||||
|
navigator.maxTouchPoints > 0 ||
|
||||||
|
navigator.msMaxTouchPoints > 0,
|
||||||
|
is_admin: false,
|
||||||
|
site_config: {
|
||||||
|
title: document.title,
|
||||||
|
subtitle: "",
|
||||||
|
content: "",
|
||||||
|
footer: "",
|
||||||
|
},
|
||||||
|
background_image: "loading",
|
||||||
|
user_doors: [],
|
||||||
|
next_door_target: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async init(): Promise<void> {
|
||||||
|
await this.update();
|
||||||
|
|
||||||
|
if (this.on_initialized !== null) {
|
||||||
|
for (const callback of this.on_initialized) callback();
|
||||||
|
}
|
||||||
|
this.on_initialized = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const favicon = await API.request<ImageData>("user/favicon");
|
||||||
|
|
||||||
|
const link: HTMLLinkElement =
|
||||||
|
document.querySelector("link[rel*='icon']") ??
|
||||||
|
document.createElement("link");
|
||||||
|
link.rel = "shortcut icon";
|
||||||
|
link.type = "image/x-icon";
|
||||||
|
link.href = favicon.data_url;
|
||||||
|
|
||||||
|
if (link.parentElement === null)
|
||||||
|
document.getElementsByTagName("head")[0]!.appendChild(link);
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [is_admin, site_config, background_image, user_doors, next_door] =
|
||||||
|
await Promise.all([
|
||||||
|
this.update_is_admin(),
|
||||||
|
API.request<SiteConfigModel>("user/site_config"),
|
||||||
|
API.request<ImageData>("user/background_image"),
|
||||||
|
API.request<DoorSaved[]>("user/doors"),
|
||||||
|
API.request<number | null>("user/next_door"),
|
||||||
|
]);
|
||||||
|
void is_admin; // discard value
|
||||||
|
|
||||||
|
document.title = site_config.title;
|
||||||
|
|
||||||
|
if (site_config.subtitle !== "")
|
||||||
|
document.title += " – " + site_config.subtitle;
|
||||||
|
|
||||||
|
this.site_config = site_config;
|
||||||
|
this.background_image = background_image;
|
||||||
|
|
||||||
|
this.user_doors.length = 0;
|
||||||
|
for (const door_saved of user_doors) {
|
||||||
|
this.user_doors.push(Door.load(door_saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next_door !== null) this.next_door_target = Date.now() + next_door;
|
||||||
|
} catch {
|
||||||
|
this.background_image = "error";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
when_initialized(callback: () => void): void {
|
||||||
|
if (this.on_initialized === null) {
|
||||||
|
callback();
|
||||||
|
} else {
|
||||||
|
this.on_initialized.push(callback);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async update_is_admin(): Promise<boolean> {
|
||||||
|
this.is_admin = await API.request<boolean>("admin/is_admin");
|
||||||
|
return this.is_admin;
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(creds: Credentials): Promise<boolean> {
|
||||||
|
API.creds = creds;
|
||||||
|
return await this.update_is_admin();
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
API.creds = null;
|
||||||
|
this.is_admin = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle_touch_device(): void {
|
||||||
|
this.is_touch_device = !this.is_touch_device;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (import.meta.webpackHot) {
|
||||||
|
import.meta.webpackHot.accept(
|
||||||
|
acceptHMRUpdate(advent22Store, import.meta.webpackHot),
|
||||||
|
);
|
||||||
|
}
|
||||||
23
ui/src/main.scss
Normal file
23
ui/src/main.scss
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
@charset "utf-8";
|
||||||
|
@use "sass:map";
|
||||||
|
|
||||||
|
//==============
|
||||||
|
// bulma
|
||||||
|
//==============
|
||||||
|
|
||||||
|
// custom color scheme
|
||||||
|
@use "bulma-scheme" as scheme;
|
||||||
|
@use "bulma/sass" with (
|
||||||
|
$primary: map.get(scheme.$colors, "primary"),
|
||||||
|
$link: map.get(scheme.$colors, "link"),
|
||||||
|
$info: map.get(scheme.$colors, "info"),
|
||||||
|
$success: map.get(scheme.$colors, "success"),
|
||||||
|
$warning: map.get(scheme.$colors, "warning"),
|
||||||
|
$danger: map.get(scheme.$colors, "danger")
|
||||||
|
);
|
||||||
|
|
||||||
|
//==============
|
||||||
|
// main imports
|
||||||
|
//==============
|
||||||
|
|
||||||
|
@forward "animate.css/animate";
|
||||||
27
ui/src/main.ts
Normal file
27
ui/src/main.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import FontAwesomeIcon from "@/lib/fontawesome";
|
||||||
|
import { advent22Store } from "@/lib/store";
|
||||||
|
import { setDefaults as toast_set_defaults } from "bulma-toast";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
|
||||||
|
import "@/main.scss";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
app.use(createPinia());
|
||||||
|
app.component("FontAwesomeIcon", FontAwesomeIcon);
|
||||||
|
|
||||||
|
advent22Store().init();
|
||||||
|
|
||||||
|
app.mount("#app");
|
||||||
|
|
||||||
|
toast_set_defaults({
|
||||||
|
duration: 10e3,
|
||||||
|
pauseOnHover: true,
|
||||||
|
dismissible: true,
|
||||||
|
closeOnClick: false,
|
||||||
|
type: "is-white",
|
||||||
|
position: "top-center",
|
||||||
|
animate: { in: "backInDown", out: "backOutUp" },
|
||||||
|
});
|
||||||
90
ui/tests/unit/rectangle.spec.ts
Normal file
90
ui/tests/unit/rectangle.spec.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { expect } from "chai";
|
||||||
|
|
||||||
|
import { Rectangle } from "@/lib/rects/rectangle";
|
||||||
|
import { Vector2D } from "@/lib/rects/vector2d";
|
||||||
|
|
||||||
|
describe("Rectangle Tests", () => {
|
||||||
|
const v1 = new Vector2D(1, 2);
|
||||||
|
const v2 = new Vector2D(4, 6);
|
||||||
|
|
||||||
|
const r1 = new Rectangle(v1, v2);
|
||||||
|
const r2 = new Rectangle(v2, v1);
|
||||||
|
|
||||||
|
function check_rectangle(
|
||||||
|
r: Rectangle,
|
||||||
|
left: number,
|
||||||
|
top: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): void {
|
||||||
|
expect(r.left).to.equal(left);
|
||||||
|
expect(r.top).to.equal(top);
|
||||||
|
|
||||||
|
expect(r.width).to.equal(width);
|
||||||
|
expect(r.height).to.equal(height);
|
||||||
|
expect(r.area).to.equal(width * height);
|
||||||
|
|
||||||
|
expect(r.middle.x).to.equal(left + 0.5 * width);
|
||||||
|
expect(r.middle.y).to.equal(top + 0.5 * height);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should create a default rectangle", () => {
|
||||||
|
check_rectangle(new Rectangle(), 0, 0, 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a rectangle", () => {
|
||||||
|
check_rectangle(r1, 1, 2, 3, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create the same rectangle backwards", () => {
|
||||||
|
check_rectangle(r2, 1, 2, 3, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should compare rectangles", () => {
|
||||||
|
expect(r1.equals(r2)).to.be.true;
|
||||||
|
expect(r1.equals(new Rectangle())).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create the same rectangle transposed", () => {
|
||||||
|
const v1t = new Vector2D(v1.x, v2.y);
|
||||||
|
const v2t = new Vector2D(v2.x, v1.y);
|
||||||
|
|
||||||
|
expect(r1.equals(new Rectangle(v1t, v2t))).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should contain itself", () => {
|
||||||
|
expect(r1.contains(v1)).to.be.true;
|
||||||
|
expect(r1.contains(v2)).to.be.true;
|
||||||
|
|
||||||
|
expect(r1.contains(r1.origin)).to.be.true;
|
||||||
|
expect(r1.contains(r1.corner)).to.be.true;
|
||||||
|
expect(r1.contains(r1.middle)).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not contain certain points", () => {
|
||||||
|
expect(r1.contains(new Vector2D(0, 0))).to.be.false;
|
||||||
|
expect(r1.contains(new Vector2D(100, 100))).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update a rectangle", () => {
|
||||||
|
const v = new Vector2D(1, 1);
|
||||||
|
|
||||||
|
check_rectangle(r1.update(v1.plus(v), undefined), 2, 3, 2, 3);
|
||||||
|
check_rectangle(r1.update(v1.minus(v), undefined), 0, 1, 4, 5);
|
||||||
|
|
||||||
|
check_rectangle(r1.update(undefined, v2.plus(v)), 1, 2, 4, 5);
|
||||||
|
check_rectangle(r1.update(undefined, v2.minus(v)), 1, 2, 2, 3);
|
||||||
|
|
||||||
|
check_rectangle(r1.update(v1.plus(v), v2.plus(v)), 2, 3, 3, 4);
|
||||||
|
check_rectangle(r1.update(v1.minus(v), v2.minus(v)), 0, 1, 3, 4);
|
||||||
|
|
||||||
|
check_rectangle(r1.update(v1.minus(v), v2.plus(v)), 0, 1, 5, 6);
|
||||||
|
check_rectangle(r1.update(v1.plus(v), v2.minus(v)), 2, 3, 1, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should move a rectangle", () => {
|
||||||
|
const v = new Vector2D(1, 1);
|
||||||
|
|
||||||
|
check_rectangle(r1.move(v), 2, 3, 3, 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
ui/tests/unit/vector2d.spec.ts
Normal file
41
ui/tests/unit/vector2d.spec.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { expect } from "chai";
|
||||||
|
|
||||||
|
import { Vector2D } from "@/lib/rects/vector2d";
|
||||||
|
|
||||||
|
describe("Vector2D Tests", () => {
|
||||||
|
const v = new Vector2D(1, 2);
|
||||||
|
|
||||||
|
it("should create a default vector", () => {
|
||||||
|
const v0 = new Vector2D();
|
||||||
|
expect(v0.x).to.equal(0);
|
||||||
|
expect(v0.y).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a vector", () => {
|
||||||
|
expect(v.x).to.equal(1);
|
||||||
|
expect(v.y).to.equal(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add vectors", () => {
|
||||||
|
const v2 = v.plus(new Vector2D(3, 4));
|
||||||
|
expect(v2.x).to.equal(4);
|
||||||
|
expect(v2.y).to.equal(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should subtract vectors", () => {
|
||||||
|
const v2 = v.minus(new Vector2D(3, 4));
|
||||||
|
expect(v2.x).to.equal(-2);
|
||||||
|
expect(v2.y).to.equal(-2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should scale vectors", () => {
|
||||||
|
const v2 = v.scale(3);
|
||||||
|
expect(v2.x).to.equal(3);
|
||||||
|
expect(v2.y).to.equal(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should compare vectors", () => {
|
||||||
|
expect(v.equals(v.scale(1))).to.be.true;
|
||||||
|
expect(v.equals(v.scale(2))).to.be.false;
|
||||||
|
});
|
||||||
|
});
|
||||||
33
ui/tsconfig.json
Normal file
33
ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"lib": [
|
||||||
|
"es2020",
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"es2022.object",
|
||||||
|
"es2023.array",
|
||||||
|
],
|
||||||
|
// "moduleResolution": "node",
|
||||||
|
// "sourceMap": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"types": [
|
||||||
|
"webpack-env",
|
||||||
|
"mocha",
|
||||||
|
"chai",
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.vue",
|
||||||
|
"src/**/*.ts",
|
||||||
|
// "src/**/*.tsx",
|
||||||
|
"tests/**/*.ts",
|
||||||
|
// "tests/**/*.tsx",
|
||||||
|
],
|
||||||
|
}
|
||||||
26
ui/vue.config.js
Normal file
26
ui/vue.config.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
const { defineConfig } = require("@vue/cli-service");
|
||||||
|
const webpack = require("webpack");
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
transpileDependencies: true,
|
||||||
|
devServer: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
index: {
|
||||||
|
entry: "src/main.ts",
|
||||||
|
title: "Kalender-Gewinnspiel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// https://stackoverflow.com/a/77765007
|
||||||
|
configureWebpack: {
|
||||||
|
plugins: [
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
// Vue CLI is in maintenance mode, and probably won't merge my PR to fix this in their tooling
|
||||||
|
// https://github.com/vuejs/vue-cli/pull/7443
|
||||||
|
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
11053
ui/yarn.lock
Normal file
11053
ui/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue