Compare commits
	
		
			No commits in common. "develop" and "master" have entirely different histories.
		
	
	
		
	
		
					 84 changed files with 0 additions and 14149 deletions
				
			
		|  | @ -1,27 +0,0 @@ | |||
| # commonly found | ||||
| **/.git | ||||
| **/.idea | ||||
| **/.DS_Store | ||||
| **/.vscode | ||||
| **/.devcontainer | ||||
| 
 | ||||
| **/dist | ||||
| **/.gitignore | ||||
| **/Dockerfile | ||||
| **/.dockerignore | ||||
| 
 | ||||
| # found in python and JS dirs | ||||
| **/__pycache__ | ||||
| **/node_modules | ||||
| **/.pytest_cache | ||||
| 
 | ||||
| # env files | ||||
| **/.env | ||||
| **/.env.local | ||||
| **/.env.*.local | ||||
| 
 | ||||
| # log files | ||||
| **/npm-debug.log* | ||||
| **/yarn-debug.log* | ||||
| **/yarn-error.log* | ||||
| **/pnpm-debug.log* | ||||
							
								
								
									
										1
									
								
								api/.gitignore → .gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								api/.gitignore → .gitignore
									
									
									
									
										vendored
									
									
								
							|  | @ -152,4 +152,3 @@ cython_debug/ | |||
| #  option (not recommended) you can uncomment the following to ignore the entire idea folder. | ||||
| #.idea/ | ||||
| 
 | ||||
| api.conf | ||||
							
								
								
									
										47
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								Dockerfile
									
									
									
									
									
								
							|  | @ -1,47 +0,0 @@ | |||
| ############ | ||||
| # build ui # | ||||
| ############ | ||||
| 
 | ||||
| ARG NODE_VERSION=18.18 | ||||
| ARG PYTHON_VERSION=3.11-slim | ||||
| FROM node:${NODE_VERSION} AS build-ui | ||||
| 
 | ||||
| # env setup | ||||
| WORKDIR /usr/local/src/advent22_ui | ||||
| 
 | ||||
| # install advent22_ui dependencies | ||||
| COPY ui/package*.json ui/yarn*.lock ./ | ||||
| RUN yarn install --production false | ||||
| 
 | ||||
| # copy and build advent22_ui | ||||
| COPY ui ./ | ||||
| RUN yarn build --dest /tmp/advent22_ui/html | ||||
| 
 | ||||
| ########### | ||||
| # web app # | ||||
| ########### | ||||
| 
 | ||||
| ARG PYTHON_VERSION | ||||
| FROM tiangolo/uvicorn-gunicorn:python${PYTHON_VERSION} AS production | ||||
| 
 | ||||
| # env setup | ||||
| WORKDIR /usr/local/src/advent22_api | ||||
| ENV \   | ||||
|     PRODUCTION_MODE="true" \ | ||||
|     PORT="8000" \ | ||||
|     MODULE_NAME="advent22_api.app" | ||||
| EXPOSE 8000 | ||||
| 
 | ||||
| # install advent22_api | ||||
| COPY api ./ | ||||
| RUN set -ex; \ | ||||
|     # remove example app | ||||
|     rm -rf /app; \ | ||||
|     \ | ||||
|     python -m pip --no-cache-dir install ./ | ||||
| 
 | ||||
| # add prepared advent22_ui | ||||
| COPY --from=build-ui /tmp/advent22_ui /usr/local/share/advent22_ui | ||||
| 
 | ||||
| # run as unprivileged user | ||||
| USER nobody | ||||
							
								
								
									
										23
									
								
								Ideen.md
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								Ideen.md
									
									
									
									
									
								
							|  | @ -1,23 +0,0 @@ | |||
| # MUSS | ||||
| 
 | ||||
| 
 | ||||
| # KANN | ||||
| 
 | ||||
| - api/ui: Türchen mit Tag "0" einem zufälligen Tag zuweisen | ||||
| - api/?: Option "custom Zuordnung Buchstaben" (standard leer) | ||||
| - ui: `confirm` durch bulma Komponente(n) ersetzen | ||||
| - halbautomatischer Modus: Finde Bilder wie "a.jpg" und "Z.png" und weise diese den passenden Tagen zu | ||||
| 
 | ||||
| # Erledigt | ||||
| 
 | ||||
| - Türchen anzeigen im DoorMapEditor | ||||
| - Lösungsbuchstaben weniger als türchen erzeugt bug | ||||
| - Türchen sichtbar machen (besser für touch, standard nein) | ||||
| - Option "Nur Groß-/Kleinbuchstaben" (standard nur groß) | ||||
| - Option "Leerzeichen ignorieren" (standard ja) | ||||
| - Nach einigen Sekunden: Meldung "Türchen anzeigen?" | ||||
| - `alert` durch bulma Komponente(n) ersetzen | ||||
| - api: admin Login case sensitivity (username "admin" == "AdMiN") | ||||
| - api: `config.solution` - whitespace="IGNORE"->"REMOVE" umbenennen, +Sonderzeichen | ||||
| - api: Config-Option "Überspringe leere Türchen" (standard ja) | ||||
| - api: Config-Liste von Extra-Türchen (kein Buchstabe, nur manuelles Bild) | ||||
|  | @ -1,50 +0,0 @@ | |||
| // For format details, see https://aka.ms/devcontainer.json. For config options, see the | ||||
| // README at: https://github.com/devcontainers/templates/tree/main/src/python | ||||
| { | ||||
| 	"name": "Advent22 API", | ||||
| 	// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile | ||||
| 	"image": "mcr.microsoft.com/vscode/devcontainers/python:1-3.11-bookworm", | ||||
| 	// Features to add to the dev container. More info: https://containers.dev/features. | ||||
| 	"features": { | ||||
| 		"ghcr.io/devcontainers-contrib/features/poetry:2": {}, | ||||
| 		"ghcr.io/devcontainers-contrib/features/apt-get-packages:1": { | ||||
| 			"packages": "git-flow, git-lfs" | ||||
| 		}, | ||||
| 		"ghcr.io/itsmechlark/features/redis-server:1": {} | ||||
| 	}, | ||||
| 	"containerEnv": { | ||||
| 		"TZ": "Europe/Berlin" | ||||
| 	}, | ||||
| 	// Configure tool-specific properties. | ||||
| 	"customizations": { | ||||
| 		// Configure properties specific to VS Code. | ||||
| 		"vscode": { | ||||
| 			// Set *default* container specific settings.json values on container create. | ||||
| 			"settings": { | ||||
| 				"python.defaultInterpreterPath": "/usr/local/bin/python", | ||||
| 				"terminal.integrated.defaultProfile.linux": "zsh" | ||||
| 			}, | ||||
| 			// Add the IDs of extensions you want installed when the container is created. | ||||
| 			"extensions": [ | ||||
| 				"be5invis.toml", | ||||
| 				"mhutchie.git-graph", | ||||
| 				"ms-python.python", | ||||
| 				"ms-python.black-formatter", | ||||
| 				"ms-python.flake8", | ||||
| 				"ms-python.isort", | ||||
| 				"ms-python.vscode-pylance" | ||||
| 			] | ||||
| 		} | ||||
| 	}, | ||||
| 	"postCreateCommand": "sudo /usr/local/py-utils/bin/poetry self add poetry-plugin-up", | ||||
| 	// Use 'postStartCommand' to run commands after the container is started. | ||||
| 	"postStartCommand": "poetry install" | ||||
| 	// Features to add to the dev container. More info: https://containers.dev/features. | ||||
| 	// "features": {}, | ||||
| 	// Use 'forwardPorts' to make a list of ports inside the container available locally. | ||||
| 	// "forwardPorts": [], | ||||
| 	// Use 'postCreateCommand' to run commands after the container is created. | ||||
| 	// "postCreateCommand": "pip3 install --user -r requirements.txt", | ||||
| 	// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. | ||||
| 	// "remoteUser": "root" | ||||
| } | ||||
|  | @ -1,4 +0,0 @@ | |||
| [flake8] | ||||
| max-line-length = 80 | ||||
| select = C,E,F,W,B,B950 | ||||
| extend-ignore = E203, E501 | ||||
							
								
								
									
										22
									
								
								api/.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								api/.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							|  | @ -1,22 +0,0 @@ | |||
| { | ||||
|     // Verwendet IntelliSense zum Ermitteln möglicher Attribute. | ||||
|     // Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen. | ||||
|     // Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387 | ||||
|     "version": "0.2.0", | ||||
|     "configurations": [ | ||||
|         { | ||||
|             "name": "Main Module", | ||||
|             "type": "python", | ||||
|             "request": "launch", | ||||
|             "module": "advent22_api.main", | ||||
|             "pythonArgs": [ | ||||
|                 "-Xfrozen_modules=off", | ||||
|             ], | ||||
|             "env": { | ||||
|                 "PYDEVD_DISABLE_FILE_VALIDATION": "1", | ||||
|                 "WEBDAV__CACHE_TTL": "30", | ||||
|             }, | ||||
|             "justMyCode": true, | ||||
|         } | ||||
|     ] | ||||
| } | ||||
							
								
								
									
										20
									
								
								api/.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								api/.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							|  | @ -1,20 +0,0 @@ | |||
| { | ||||
|   "python.languageServer": "Pylance", | ||||
|   "editor.formatOnSave": true, | ||||
|   "[python]": { | ||||
|     "editor.defaultFormatter": "ms-python.black-formatter" | ||||
|   }, | ||||
|   "editor.codeActionsOnSave": { | ||||
|     "source.organizeImports": "explicit" | ||||
|   }, | ||||
|   "git.closeDiffOnOperation": true, | ||||
|   "python.analysis.typeCheckingMode": "basic", | ||||
|   "python.analysis.diagnosticMode": "workspace", | ||||
|   "python.testing.pytestArgs": [ | ||||
|     "test" | ||||
|   ], | ||||
|   "python.testing.unittestEnabled": false, | ||||
|   "python.testing.pytestEnabled": true, | ||||
|   "black-formatter.importStrategy": "fromEnvironment", | ||||
|   "flake8.importStrategy": "fromEnvironment", | ||||
| } | ||||
|  | @ -1,46 +0,0 @@ | |||
| from fastapi import FastAPI | ||||
| from fastapi.middleware.cors import CORSMiddleware | ||||
| from fastapi.staticfiles import StaticFiles | ||||
| 
 | ||||
| from .core.settings import SETTINGS | ||||
| from .routers import router | ||||
| 
 | ||||
| app = FastAPI( | ||||
|     title="Advent22 API", | ||||
|     description="This API enables the `Advent22` service.", | ||||
|     contact={ | ||||
|         "name": "Jörn-Michael Miehe", | ||||
|         "email": "jmm@yavook.de", | ||||
|     }, | ||||
|     license_info={ | ||||
|         "name": "MIT License", | ||||
|         "url": "https://opensource.org/licenses/mit-license.php", | ||||
|     }, | ||||
|     openapi_url=SETTINGS.openapi_url, | ||||
|     docs_url=SETTINGS.docs_url, | ||||
|     redoc_url=SETTINGS.redoc_url, | ||||
| ) | ||||
| 
 | ||||
| app.include_router(router) | ||||
| 
 | ||||
| if SETTINGS.production_mode: | ||||
|     # Mount frontend in production mode | ||||
|     app.mount( | ||||
|         path="/", | ||||
|         app=StaticFiles( | ||||
|             directory=SETTINGS.ui_directory, | ||||
|             html=True, | ||||
|         ), | ||||
|         name="frontend", | ||||
|     ) | ||||
| 
 | ||||
| else: | ||||
|     # Allow CORS in debug mode | ||||
|     app.add_middleware( | ||||
|         CORSMiddleware, | ||||
|         allow_credentials=True, | ||||
|         allow_origins=["*"], | ||||
|         allow_methods=["*"], | ||||
|         allow_headers=["*"], | ||||
|         expose_headers=["*"], | ||||
|     ) | ||||
|  | @ -1,138 +0,0 @@ | |||
| import colorsys | ||||
| from dataclasses import dataclass | ||||
| from typing import Self, TypeAlias, cast | ||||
| 
 | ||||
| import numpy as np | ||||
| from PIL import Image, ImageDraw, ImageFont | ||||
| 
 | ||||
| from .config import Config | ||||
| 
 | ||||
| _RGB: TypeAlias = tuple[int, int, int] | ||||
| _XY: TypeAlias = tuple[float, float] | ||||
| 
 | ||||
| 
 | ||||
| @dataclass(slots=True, frozen=True) | ||||
| class AdventImage: | ||||
|     img: Image.Image | ||||
| 
 | ||||
|     @classmethod | ||||
|     async def from_img(cls, img: Image.Image, cfg: Config) -> Self: | ||||
|         """ | ||||
|         Einen quadratischen Ausschnitt aus der Mitte des Bilds nehmen | ||||
|         """ | ||||
| 
 | ||||
|         # Farbmodell festlegen | ||||
|         img = img.convert(mode="RGB") | ||||
| 
 | ||||
|         # Größen bestimmen | ||||
|         width, height = img.size | ||||
|         square = min(width, height) | ||||
| 
 | ||||
|         # zuschneiden | ||||
|         img = img.crop( | ||||
|             box=( | ||||
|                 int((width - square) / 2), | ||||
|                 int((height - square) / 2), | ||||
|                 int((width + square) / 2), | ||||
|                 int((height + square) / 2), | ||||
|             ) | ||||
|         ) | ||||
| 
 | ||||
|         # skalieren | ||||
|         return cls( | ||||
|             img.resize( | ||||
|                 size=(cfg.image.size, cfg.image.size), | ||||
|                 resample=Image.LANCZOS, | ||||
|             ) | ||||
|         ) | ||||
| 
 | ||||
|     async def get_text_box( | ||||
|         self, | ||||
|         xy: _XY, | ||||
|         text: str | bytes, | ||||
|         font: "ImageFont._Font", | ||||
|         anchor: str | None = "mm", | ||||
|         **text_kwargs, | ||||
|     ) -> "Image._Box | None": | ||||
|         """ | ||||
|         Koordinaten (links, oben, rechts, unten) des betroffenen | ||||
|         Rechtecks bestimmen, wenn das Bild mit einem Text | ||||
|         versehen wird | ||||
|         """ | ||||
| 
 | ||||
|         # Neues 1-Bit Bild, gleiche Größe | ||||
|         mask = Image.new(mode="1", size=self.img.size, color=0) | ||||
| 
 | ||||
|         # Text auf Maske auftragen | ||||
|         ImageDraw.Draw(mask).text( | ||||
|             xy=xy, | ||||
|             text=text, | ||||
|             font=font, | ||||
|             anchor=anchor, | ||||
|             fill=1, | ||||
|             **text_kwargs, | ||||
|         ) | ||||
| 
 | ||||
|         # betroffenen Pixelbereich bestimmen | ||||
|         return mask.getbbox() | ||||
| 
 | ||||
|     async def get_average_color( | ||||
|         self, | ||||
|         box: "Image._Box", | ||||
|     ) -> tuple[int, int, int]: | ||||
|         """ | ||||
|         Durchschnittsfarbe eines rechteckigen Ausschnitts in | ||||
|         einem Bild berechnen | ||||
|         """ | ||||
| 
 | ||||
|         pixel_data = self.img.crop(box).getdata() | ||||
|         mean_color: np.ndarray = np.mean(pixel_data, axis=0) | ||||
| 
 | ||||
|         return cast(_RGB, tuple(mean_color.astype(int))) | ||||
| 
 | ||||
|     async def hide_text( | ||||
|         self, | ||||
|         xy: _XY, | ||||
|         text: str | bytes, | ||||
|         font: "ImageFont._Font", | ||||
|         anchor: str | None = "mm", | ||||
|         **text_kwargs, | ||||
|     ) -> None: | ||||
|         """ | ||||
|         Text `text` in Bild an Position `xy` verstecken. | ||||
|         Weitere Parameter wie bei `ImageDraw.text()`. | ||||
|         """ | ||||
| 
 | ||||
|         # betroffenen Bildbereich bestimmen | ||||
|         text_box = await self.get_text_box( | ||||
|             xy=xy, text=text, font=font, anchor=anchor, **text_kwargs | ||||
|         ) | ||||
| 
 | ||||
|         if text_box is not None: | ||||
|             # Durchschnittsfarbe bestimmen | ||||
|             text_color = await self.get_average_color( | ||||
|                 box=text_box, | ||||
|             ) | ||||
| 
 | ||||
|             # etwas heller/dunkler machen | ||||
|             tc_h, tc_s, tc_v = colorsys.rgb_to_hsv(*text_color) | ||||
|             tc_v = int((tc_v - 127) * 0.97) + 127 | ||||
| 
 | ||||
|             if tc_v < 127: | ||||
|                 tc_v += 3 | ||||
| 
 | ||||
|             else: | ||||
|                 tc_v -= 3 | ||||
| 
 | ||||
|             text_color = colorsys.hsv_to_rgb(tc_h, tc_s, tc_v) | ||||
|             text_color = tuple(int(val) for val in text_color) | ||||
| 
 | ||||
|             # Buchstaben verstecken | ||||
|             ImageDraw.Draw(self.img).text( | ||||
|                 xy=xy, | ||||
|                 text=text, | ||||
|                 font=font, | ||||
|                 fill=cast(_RGB, text_color), | ||||
|                 anchor=anchor, | ||||
|                 **text_kwargs, | ||||
|             ) | ||||
|  | @ -1,55 +0,0 @@ | |||
| import tomllib | ||||
| from typing import TypeAlias | ||||
| 
 | ||||
| import tomli_w | ||||
| from fastapi import Depends | ||||
| from pydantic import BaseModel | ||||
| 
 | ||||
| from .config import Config, get_config | ||||
| from .dav.webdav import WebDAV | ||||
| 
 | ||||
| 
 | ||||
| class DoorSaved(BaseModel): | ||||
|     # Tag, an dem die Tür aufgeht | ||||
|     day: int | ||||
| 
 | ||||
|     # Koordinaten für zwei Eckpunkte | ||||
|     x1: int | ||||
|     y1: int | ||||
|     x2: int | ||||
|     y2: int | ||||
| 
 | ||||
| 
 | ||||
| DoorsSaved: TypeAlias = list[DoorSaved] | ||||
| 
 | ||||
| 
 | ||||
| class CalendarConfig(BaseModel): | ||||
|     # Dateiname Hintergrundbild | ||||
|     background: str = "adventskalender.jpg" | ||||
| 
 | ||||
|     # Dateiname Favicon | ||||
|     favicon: str = "favicon.png" | ||||
| 
 | ||||
|     # Türen für die UI | ||||
|     doors: DoorsSaved = [] | ||||
| 
 | ||||
|     async def change(self, cfg: Config) -> None: | ||||
|         """ | ||||
|         Kalender Konfiguration ändern | ||||
|         """ | ||||
| 
 | ||||
|         await WebDAV.write_str( | ||||
|             path=f"files/{cfg.calendar}", | ||||
|             content=tomli_w.dumps(self.model_dump()), | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
| async def get_calendar_config( | ||||
|     cfg: Config = Depends(get_config), | ||||
| ) -> CalendarConfig: | ||||
|     """ | ||||
|     Kalender Konfiguration lesen | ||||
|     """ | ||||
| 
 | ||||
|     txt = await WebDAV.read_str(path=f"files/{cfg.calendar}") | ||||
|     return CalendarConfig.model_validate(tomllib.loads(txt)) | ||||
|  | @ -1,86 +0,0 @@ | |||
| import tomllib | ||||
| 
 | ||||
| from markdown import markdown | ||||
| from pydantic import BaseModel, ConfigDict, field_validator | ||||
| 
 | ||||
| from .dav.webdav import WebDAV | ||||
| from .settings import SETTINGS | ||||
| from .transformed_string import TransformedString | ||||
| 
 | ||||
| 
 | ||||
| class User(BaseModel): | ||||
|     name: str | ||||
|     password: str | ||||
| 
 | ||||
| 
 | ||||
| class Site(BaseModel): | ||||
|     model_config = ConfigDict(validate_default=True) | ||||
| 
 | ||||
|     # Titel | ||||
|     title: str | ||||
| 
 | ||||
|     # Untertitel | ||||
|     subtitle: str | ||||
| 
 | ||||
|     # Inhalt der Seite | ||||
|     content: str | ||||
| 
 | ||||
|     # Fußzeile der Seite | ||||
|     footer: str = "**Advent22** by [Lenaisten e.V.](//www.lenaisten.de)" | ||||
| 
 | ||||
|     @field_validator("content", "footer", mode="after") | ||||
|     def parse_md(cls, v) -> str: | ||||
|         return markdown(v) | ||||
| 
 | ||||
| 
 | ||||
| class Puzzle(BaseModel): | ||||
|     # Tag, an dem der Kalender startet | ||||
|     begin_day: int = 1 | ||||
| 
 | ||||
|     # Monat, in dem der Kalender startet | ||||
|     begin_month: int = 12 | ||||
| 
 | ||||
|     # Kalender so viele Tage nach der letzten Türöffnung schließen | ||||
|     close_after: int = 90 | ||||
| 
 | ||||
|     # Tage, für die kein Buchstabe vorgesehen wird | ||||
|     extra_days: set[int] = set() | ||||
| 
 | ||||
|     # Türchen ohne Buchstabe überspringen | ||||
|     skip_empty: bool = True | ||||
| 
 | ||||
| 
 | ||||
| class Image(BaseModel): | ||||
|     # Quadrat, Seitenlänge in px | ||||
|     size: int = 1000 | ||||
| 
 | ||||
|     # Rand in px, wo keine Buchstaben untergebracht werden | ||||
|     border: int = 60 | ||||
| 
 | ||||
| 
 | ||||
| class Config(BaseModel): | ||||
|     # Login-Daten für Admin-Modus | ||||
|     admin: User | ||||
| 
 | ||||
|     # Lösungswort | ||||
|     solution: TransformedString | ||||
| 
 | ||||
|     # Weitere Einstellungen | ||||
|     site: Site | ||||
|     puzzle: Puzzle | ||||
|     image: Image | ||||
| 
 | ||||
|     # Kalenderdefinition | ||||
|     calendar: str = "default.toml" | ||||
| 
 | ||||
|     # Serverseitiger zusätzlicher "random" seed | ||||
|     random_seed: str = "" | ||||
| 
 | ||||
| 
 | ||||
| async def get_config() -> Config: | ||||
|     """ | ||||
|     Globale Konfiguration lesen | ||||
|     """ | ||||
| 
 | ||||
|     txt = await WebDAV.read_str(path=SETTINGS.webdav.config_filename) | ||||
|     return Config.model_validate(tomllib.loads(txt)) | ||||
|  | @ -1,61 +0,0 @@ | |||
| from json import JSONDecodeError | ||||
| from typing import Callable, Hashable | ||||
| 
 | ||||
| import requests | ||||
| from cachetools.keys import hashkey | ||||
| from CacheToolsUtils import RedisCache as __RedisCache | ||||
| from redis.commands.core import ResponseT | ||||
| from redis.typing import EncodableT | ||||
| from webdav3.client import Client as __WebDAVclient | ||||
| 
 | ||||
| 
 | ||||
| def davkey( | ||||
|     name: str, | ||||
|     slice: slice = slice(1, None), | ||||
| ) -> Callable[..., tuple[Hashable, ...]]: | ||||
|     def func(*args, **kwargs) -> tuple[Hashable, ...]: | ||||
|         """Return a cache key for use with cached methods.""" | ||||
| 
 | ||||
|         key = hashkey(name, *args[slice], **kwargs) | ||||
|         return hashkey(*(str(key_item) for key_item in key)) | ||||
| 
 | ||||
|     return func | ||||
| 
 | ||||
| 
 | ||||
| class WebDAVclient(__WebDAVclient): | ||||
|     def execute_request( | ||||
|         self, | ||||
|         action, | ||||
|         path, | ||||
|         data=None, | ||||
|         headers_ext=None, | ||||
|     ) -> requests.Response: | ||||
|         res = super().execute_request(action, path, data, headers_ext) | ||||
| 
 | ||||
|         # the "Content-Length" header can randomly be missing on txt files, | ||||
|         # this should fix that (probably serverside bug) | ||||
|         if action == "download" and "Content-Length" not in res.headers: | ||||
|             res.headers["Content-Length"] = str(len(res.text)) | ||||
| 
 | ||||
|         return res | ||||
| 
 | ||||
| 
 | ||||
| class RedisCache(__RedisCache): | ||||
|     """ | ||||
|     Redis handles <bytes>, so ... | ||||
|     """ | ||||
| 
 | ||||
|     def _serialize(self, s) -> EncodableT: | ||||
|         if isinstance(s, bytes): | ||||
|             return s | ||||
| 
 | ||||
|         else: | ||||
|             return super()._serialize(s) | ||||
| 
 | ||||
|     def _deserialize(self, s: ResponseT): | ||||
|         try: | ||||
|             return super()._deserialize(s) | ||||
| 
 | ||||
|         except (UnicodeDecodeError, JSONDecodeError): | ||||
|             assert isinstance(s, bytes) | ||||
|             return s | ||||
|  | @ -1,108 +0,0 @@ | |||
| import logging | ||||
| import re | ||||
| from io import BytesIO | ||||
| 
 | ||||
| from asyncify import asyncify | ||||
| from cachetools import cachedmethod | ||||
| from redis import Redis | ||||
| 
 | ||||
| from ..settings import SETTINGS | ||||
| from .helpers import RedisCache, WebDAVclient, davkey | ||||
| 
 | ||||
| _logger = logging.getLogger(__name__) | ||||
| 
 | ||||
| 
 | ||||
| class WebDAV: | ||||
|     _webdav_client = WebDAVclient( | ||||
|         { | ||||
|             "webdav_hostname": SETTINGS.webdav.url, | ||||
|             "webdav_login": SETTINGS.webdav.username, | ||||
|             "webdav_password": SETTINGS.webdav.password, | ||||
|         } | ||||
|     ) | ||||
| 
 | ||||
|     _cache = RedisCache( | ||||
|         cache=Redis( | ||||
|             host=SETTINGS.redis.host, | ||||
|             port=SETTINGS.redis.port, | ||||
|             db=SETTINGS.redis.db, | ||||
|             protocol=SETTINGS.redis.protocol, | ||||
|         ), | ||||
|         ttl=SETTINGS.webdav.cache_ttl, | ||||
|     ) | ||||
| 
 | ||||
|     @classmethod | ||||
|     @asyncify | ||||
|     @cachedmethod(cache=lambda cls: cls._cache, key=davkey("list_files")) | ||||
|     def list_files( | ||||
|         cls, | ||||
|         directory: str = "", | ||||
|         *, | ||||
|         regex: re.Pattern[str] = re.compile(""), | ||||
|     ) -> list[str]: | ||||
|         """ | ||||
|         List files in directory `directory` matching RegEx `regex` | ||||
|         """ | ||||
| 
 | ||||
|         _logger.debug(f"list_files {directory!r}") | ||||
|         ls = cls._webdav_client.list(directory) | ||||
| 
 | ||||
|         return [path for path in ls if regex.search(path)] | ||||
| 
 | ||||
|     @classmethod | ||||
|     @asyncify | ||||
|     @cachedmethod(cache=lambda cls: cls._cache, key=davkey("exists")) | ||||
|     def exists(cls, path: str) -> bool: | ||||
|         """ | ||||
|         `True` iff there is a WebDAV resource at `path` | ||||
|         """ | ||||
| 
 | ||||
|         _logger.debug(f"file_exists {path!r}") | ||||
|         return cls._webdav_client.check(path) | ||||
| 
 | ||||
|     @classmethod | ||||
|     @asyncify | ||||
|     @cachedmethod(cache=lambda cls: cls._cache, key=davkey("read_bytes")) | ||||
|     def read_bytes(cls, path: str) -> bytes: | ||||
|         """ | ||||
|         Load WebDAV file from `path` as bytes | ||||
|         """ | ||||
| 
 | ||||
|         _logger.debug(f"read_bytes {path!r}") | ||||
|         buffer = BytesIO() | ||||
|         cls._webdav_client.download_from(buffer, path) | ||||
|         buffer.seek(0) | ||||
| 
 | ||||
|         return buffer.read() | ||||
| 
 | ||||
|     @classmethod | ||||
|     async def read_str(cls, path: str, encoding="utf-8") -> str: | ||||
|         """ | ||||
|         Load WebDAV file from `path` as string | ||||
|         """ | ||||
| 
 | ||||
|         _logger.debug(f"read_str {path!r}") | ||||
|         return (await cls.read_bytes(path)).decode(encoding=encoding).strip() | ||||
| 
 | ||||
|     @classmethod | ||||
|     @asyncify | ||||
|     def write_bytes(cls, path: str, buffer: bytes) -> None: | ||||
|         """ | ||||
|         Write bytes from `buffer` into WebDAV file at `path` | ||||
|         """ | ||||
| 
 | ||||
|         _logger.debug(f"write_bytes {path!r}") | ||||
|         cls._webdav_client.upload_to(buffer, path) | ||||
| 
 | ||||
|         # invalidate cache entry | ||||
|         # explicit slice as there is no "cls" argument | ||||
|         del cls._cache[davkey("read_bytes", slice(0, None))(path)] | ||||
| 
 | ||||
|     @classmethod | ||||
|     async def write_str(cls, path: str, content: str, encoding="utf-8") -> None: | ||||
|         """ | ||||
|         Write string from `content` into WebDAV file at `path` | ||||
|         """ | ||||
| 
 | ||||
|         _logger.debug(f"write_str {path!r}") | ||||
|         await cls.write_bytes(path, content.encode(encoding=encoding)) | ||||
|  | @ -1,227 +0,0 @@ | |||
| import re | ||||
| from dataclasses import dataclass | ||||
| from datetime import date | ||||
| from io import BytesIO | ||||
| from typing import cast | ||||
| 
 | ||||
| from fastapi import Depends | ||||
| from PIL import Image, ImageFont | ||||
| 
 | ||||
| from .advent_image import _XY, AdventImage | ||||
| from .calendar_config import CalendarConfig, get_calendar_config | ||||
| from .config import Config, get_config | ||||
| from .dav.webdav import WebDAV | ||||
| from .helpers import ( | ||||
|     RE_TTF, | ||||
|     EventDates, | ||||
|     Random, | ||||
|     list_fonts, | ||||
|     list_images_auto, | ||||
|     list_images_manual, | ||||
|     load_image, | ||||
|     set_len, | ||||
| ) | ||||
| 
 | ||||
| 
 | ||||
| async def get_all_sorted_days( | ||||
|     cal_cfg: CalendarConfig = Depends(get_calendar_config), | ||||
| ) -> list[int]: | ||||
|     """ | ||||
|     Alle Tage, für die es ein Türchen gibt | ||||
|     """ | ||||
| 
 | ||||
|     return sorted(set(door.day for door in cal_cfg.doors)) | ||||
| 
 | ||||
| 
 | ||||
| async def get_all_parts( | ||||
|     cfg: Config = Depends(get_config), | ||||
|     days: list[int] = Depends(get_all_sorted_days), | ||||
| ) -> dict[int, str]: | ||||
|     """ | ||||
|     Lösung auf vorhandene Tage aufteilen | ||||
|     """ | ||||
| 
 | ||||
|     # noch keine Buchstaben verteilt | ||||
|     result = {day: "" for day in days} | ||||
|     # extra-Tage ausfiltern | ||||
|     days = [day for day in days if day not in cfg.puzzle.extra_days] | ||||
| 
 | ||||
|     solution_length = len(cfg.solution.clean) | ||||
|     num_days = len(days) | ||||
| 
 | ||||
|     rnd = await Random.get() | ||||
|     solution_days = [ | ||||
|         # wie oft passen die Tage "ganz" in die Länge der Lösung? | ||||
|         # zB 26 Buchstaben // 10 Tage == 2 mal => 2 Zeichen pro Tag | ||||
|         *rnd.shuffled(days * (solution_length // num_days)), | ||||
|         # wie viele Buchstaben bleiben übrig? | ||||
|         # zB 26 % 10 == 6 Buchstaben => an 6 Tagen ein Zeichen mehr | ||||
|         *rnd.sample(days, solution_length % num_days), | ||||
|     ] | ||||
| 
 | ||||
|     for day, letter in zip(solution_days, cfg.solution.clean): | ||||
|         result[day] += letter | ||||
| 
 | ||||
|     return result | ||||
| 
 | ||||
| 
 | ||||
| async def get_all_event_dates( | ||||
|     cfg: Config = Depends(get_config), | ||||
|     days: list[int] = Depends(get_all_sorted_days), | ||||
|     parts: dict[int, str] = Depends(get_all_parts), | ||||
| ) -> EventDates: | ||||
|     """ | ||||
|     Aktueller Kalender-Zeitraum | ||||
|     """ | ||||
| 
 | ||||
|     if cfg.puzzle.skip_empty: | ||||
|         days = [day for day in days if parts[day] != "" or day in cfg.puzzle.extra_days] | ||||
| 
 | ||||
|     return EventDates( | ||||
|         today=date.today(), | ||||
|         begin_month=cfg.puzzle.begin_month, | ||||
|         begin_day=cfg.puzzle.begin_day, | ||||
|         events=days, | ||||
|         close_after=cfg.puzzle.close_after, | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| async def get_all_auto_image_names( | ||||
|     days: list[int] = Depends(get_all_sorted_days), | ||||
|     images: list[str] = Depends(list_images_auto), | ||||
| ) -> dict[int, str]: | ||||
|     """ | ||||
|     Bilder: Reihenfolge zufällig bestimmen | ||||
|     """ | ||||
| 
 | ||||
|     rnd = await Random.get() | ||||
|     ls = set_len(images, len(days)) | ||||
| 
 | ||||
|     return dict(zip(days, rnd.shuffled(ls))) | ||||
| 
 | ||||
| 
 | ||||
| async def get_all_manual_image_names( | ||||
|     manual_image_names: list[str] = Depends(list_images_manual), | ||||
| ) -> dict[int, str]: | ||||
|     """ | ||||
|     Bilder: "manual" zuordnen | ||||
|     """ | ||||
| 
 | ||||
|     num_re = re.compile(r"/(\d+)\.", flags=re.IGNORECASE) | ||||
|     return { | ||||
|         int(num_match.group(1)): name | ||||
|         for name in manual_image_names | ||||
|         if (num_match := num_re.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) -> "ImageFont._Font": | ||||
|         return ImageFont.truetype( | ||||
|             font=BytesIO(await WebDAV.read_bytes(self.file_name)), | ||||
|             size=100, | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
| async def get_all_ttfonts( | ||||
|     font_names: list[str] = Depends(list_fonts), | ||||
| ) -> list[TTFont]: | ||||
|     result = [] | ||||
| 
 | ||||
|     for name in font_names: | ||||
|         assert (size_match := RE_TTF.search(name)) is not None | ||||
| 
 | ||||
|         result.append( | ||||
|             TTFont( | ||||
|                 file_name=name, | ||||
|                 size=int(size_match.group(1)), | ||||
|             ) | ||||
|         ) | ||||
| 
 | ||||
|     return result | ||||
| 
 | ||||
| 
 | ||||
| async def gen_day_auto_image( | ||||
|     day: int, | ||||
|     cfg: Config, | ||||
|     auto_image_names: dict[int, str], | ||||
|     day_parts: dict[int, str], | ||||
|     ttfonts: list[TTFont], | ||||
| ) -> Image.Image: | ||||
|     """ | ||||
|     Automatisch generiertes Bild erstellen | ||||
|     """ | ||||
| 
 | ||||
|     # Datei existiert garantiert! | ||||
|     img = await load_image(auto_image_names[day]) | ||||
|     image = await AdventImage.from_img(img, cfg) | ||||
| 
 | ||||
|     rnd = await Random.get(day) | ||||
|     xy_range = range(cfg.image.border, (cfg.image.size - cfg.image.border)) | ||||
| 
 | ||||
|     # Buchstaben verstecken | ||||
|     for letter in day_parts[day]: | ||||
|         await image.hide_text( | ||||
|             xy=cast(_XY, tuple(rnd.choices(xy_range, k=2))), | ||||
|             text=letter, | ||||
|             font=await rnd.choice(ttfonts).font, | ||||
|         ) | ||||
| 
 | ||||
|     return image.img | ||||
| 
 | ||||
| 
 | ||||
| async def get_day_image( | ||||
|     day: int, | ||||
|     days: list[int] = Depends(get_all_sorted_days), | ||||
|     cfg: Config = Depends(get_config), | ||||
|     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.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, | ||||
|         ) | ||||
|  | @ -1,216 +0,0 @@ | |||
| import itertools | ||||
| import random | ||||
| import re | ||||
| from datetime import date, datetime, timedelta | ||||
| from io import BytesIO | ||||
| from typing import Any, Awaitable, Callable, Iterable, Self, Sequence, TypeVar | ||||
| 
 | ||||
| from fastapi.responses import StreamingResponse | ||||
| from PIL import Image | ||||
| 
 | ||||
| from .config import get_config | ||||
| from .dav.webdav import WebDAV | ||||
| 
 | ||||
| T = TypeVar("T") | ||||
| RE_IMG = re.compile(r"\.(gif|jpe?g|tiff?|png|bmp)$", flags=re.IGNORECASE) | ||||
| RE_TTF = re.compile(r"_(\d+)\.ttf$", flags=re.IGNORECASE) | ||||
| 
 | ||||
| 
 | ||||
| class Random(random.Random): | ||||
|     @classmethod | ||||
|     async def get(cls, bonus_salt: Any = "") -> Self: | ||||
|         cfg = await get_config() | ||||
|         return cls(f"{cfg.solution.clean}{cfg.random_seed}{bonus_salt}") | ||||
| 
 | ||||
|     def shuffled(self, population: Sequence[T]) -> Sequence[T]: | ||||
|         return self.sample(population, k=len(population)) | ||||
| 
 | ||||
| 
 | ||||
| def set_len(seq: Sequence[T], len: int) -> Sequence[T]: | ||||
|     # `seq` unendlich wiederholen | ||||
|     infinite = itertools.cycle(seq) | ||||
| 
 | ||||
|     # Die ersten `length` einträge nehmen | ||||
|     return list(itertools.islice(infinite, len)) | ||||
| 
 | ||||
| 
 | ||||
| def spread( | ||||
|     given: Iterable[int], | ||||
|     n: int, | ||||
|     rnd: Random | None = None, | ||||
| ) -> list[int]: | ||||
|     """ | ||||
|     Zu `given` ganzen Zahlen `n` zusätzliche Zahlen hinzunehmen. | ||||
| 
 | ||||
|     - Die neuen Werte sind im selben Zahlenbereich wie `given` | ||||
|     - Zuerst werden alle Werte "zwischen" den `given` Werten genommen | ||||
|     """ | ||||
| 
 | ||||
|     if n == 0: | ||||
|         return [] | ||||
| 
 | ||||
|     if len(set(given)) > 1: | ||||
|         range_given = range(min(given), max(given) + 1) | ||||
|         first_round = set(range_given) - set(given) | ||||
| 
 | ||||
|     elif len(set(given)) == 1: | ||||
|         if (a := next(iter(given))) > 0: | ||||
|             range_given = range(1, a + 1) | ||||
|         else: | ||||
|             range_given = range(1, n + 1) | ||||
| 
 | ||||
|         first_round = set(range_given) - set(given) | ||||
| 
 | ||||
|     else: | ||||
|         range_given = range(1, n + 1) | ||||
|         first_round = range_given | ||||
| 
 | ||||
|     result = sorted(first_round)[: min(n, len(first_round))] | ||||
| 
 | ||||
|     full_rounds = (n - len(result)) // len(range_given) | ||||
|     result += list(range_given) * full_rounds | ||||
| 
 | ||||
|     remain = n - len(result) | ||||
|     if rnd is None: | ||||
|         result += list(range_given)[:remain] | ||||
| 
 | ||||
|     else: | ||||
|         result += rnd.sample(range_given, remain) | ||||
|         rnd.shuffle(result) | ||||
| 
 | ||||
|     return result | ||||
| 
 | ||||
| 
 | ||||
| def list_helper( | ||||
|     directory: str, | ||||
|     regex: re.Pattern[str], | ||||
| ) -> Callable[[], Awaitable[list[str]]]: | ||||
|     """ | ||||
|     Finde alle Dateien im Verzeichnis `dir`, passend zu `re` | ||||
|     """ | ||||
| 
 | ||||
|     async def _list_helper() -> list[str]: | ||||
|         return [ | ||||
|             f"{directory}/{file}" | ||||
|             for file in await WebDAV.list_files(directory=directory, regex=regex) | ||||
|         ] | ||||
| 
 | ||||
|     return _list_helper | ||||
| 
 | ||||
| 
 | ||||
| list_images_auto = list_helper("/images_auto", RE_IMG) | ||||
| list_images_manual = list_helper("/images_manual", RE_IMG) | ||||
| list_fonts = list_helper("/files", RE_TTF) | ||||
| 
 | ||||
| 
 | ||||
| async def load_image(file_name: str) -> Image.Image: | ||||
|     """ | ||||
|     Versuche, Bild aus Datei zu laden | ||||
|     """ | ||||
| 
 | ||||
|     if not await WebDAV.exists(file_name): | ||||
|         raise RuntimeError(f"DAV-File {file_name} does not exist!") | ||||
| 
 | ||||
|     return Image.open(BytesIO(await WebDAV.read_bytes(file_name))) | ||||
| 
 | ||||
| 
 | ||||
| async def api_return_ico(img: Image.Image) -> StreamingResponse: | ||||
|     """ | ||||
|     ICO-Bild mit API zurückgeben | ||||
|     """ | ||||
| 
 | ||||
|     # JPEG-Daten in Puffer speichern | ||||
|     img_buffer = BytesIO() | ||||
|     img.resize(size=(256, 256), resample=Image.LANCZOS) | ||||
|     img.save(img_buffer, format="ICO") | ||||
|     img_buffer.seek(0) | ||||
| 
 | ||||
|     # zurückgeben | ||||
|     return StreamingResponse( | ||||
|         media_type="image/x-icon", | ||||
|         content=img_buffer, | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| async def api_return_jpeg(img: Image.Image) -> StreamingResponse: | ||||
|     """ | ||||
|     JPEG-Bild mit API zurückgeben | ||||
|     """ | ||||
| 
 | ||||
|     # JPEG-Daten in Puffer speichern | ||||
|     img_buffer = BytesIO() | ||||
|     img.save(img_buffer, format="JPEG", quality=85) | ||||
|     img_buffer.seek(0) | ||||
| 
 | ||||
|     # zurückgeben | ||||
|     return StreamingResponse( | ||||
|         media_type="image/jpeg", | ||||
|         content=img_buffer, | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| class EventDates: | ||||
|     """ | ||||
|     Events in einem Ereigniszeitraum | ||||
|     """ | ||||
| 
 | ||||
|     __overall_duration: timedelta | ||||
|     dates: dict[int, date] | ||||
| 
 | ||||
|     @property | ||||
|     def first(self) -> date: | ||||
|         """Datum des ersten Ereignisses""" | ||||
|         return self.dates[min(self.dates.keys())] | ||||
| 
 | ||||
|     def get_next(self, *, today: date) -> date | None: | ||||
|         """Datum des nächsten Ereignisses""" | ||||
|         return next( | ||||
|             (event for event in sorted(self.dates.values()) if event > today), None | ||||
|         ) | ||||
| 
 | ||||
|     @property | ||||
|     def next(self) -> date | None: | ||||
|         """Datum des nächsten Ereignisses""" | ||||
|         return self.get_next(today=date.today()) | ||||
| 
 | ||||
|     @property | ||||
|     def last(self) -> date: | ||||
|         """Datum des letzten Ereignisses""" | ||||
|         return self.dates[max(self.dates.keys())] | ||||
| 
 | ||||
|     @property | ||||
|     def end(self) -> date: | ||||
|         """Letztes Datum des Ereigniszeitraums""" | ||||
|         return self.first + self.__overall_duration | ||||
| 
 | ||||
|     def __init__( | ||||
|         self, | ||||
|         *, | ||||
|         # current date | ||||
|         today: date, | ||||
|         # month/day when events begin | ||||
|         begin_month: int, | ||||
|         begin_day: int, | ||||
|         # events: e.g. a 2 means there is an event on the 2nd day | ||||
|         # i.e. 1 day after begin | ||||
|         # - assume sorted (ascending) | ||||
|         events: list[int], | ||||
|         # countdown to closing begins after last event | ||||
|         close_after: int, | ||||
|     ) -> None: | ||||
|         # account for the last event, then add closing period | ||||
|         self.__overall_duration = timedelta(days=events[-1] - 1 + close_after) | ||||
| 
 | ||||
|         # the events may begin last year, this year or next year | ||||
|         maybe_begin = ( | ||||
|             datetime(today.year + year_diff, begin_month, begin_day).date() | ||||
|             for year_diff in (-1, 0, +1) | ||||
|         ) | ||||
| 
 | ||||
|         # find the first begin where the end date is in the future | ||||
|         begin = next( | ||||
|             begin for begin in maybe_begin if today <= (begin + self.__overall_duration) | ||||
|         ) | ||||
| 
 | ||||
|         # all event dates | ||||
|         self.dates = {event: begin + timedelta(days=event - 1) for event in events} | ||||
|  | @ -1,93 +0,0 @@ | |||
| from typing import TypeVar | ||||
| 
 | ||||
| from pydantic import BaseModel | ||||
| from pydantic_settings import BaseSettings, SettingsConfigDict | ||||
| 
 | ||||
| T = TypeVar("T") | ||||
| 
 | ||||
| 
 | ||||
| class DavSettings(BaseModel): | ||||
|     """ | ||||
|     Connection to a DAV server. | ||||
|     """ | ||||
| 
 | ||||
|     protocol: str = "https" | ||||
|     host: str = "example.com" | ||||
|     path: str = "/remote.php/webdav" | ||||
|     prefix: str = "/advent22" | ||||
| 
 | ||||
|     username: str = "advent22_user" | ||||
|     password: str = "password" | ||||
| 
 | ||||
|     cache_ttl: int = 60 * 10 | ||||
|     config_filename: str = "config.toml" | ||||
| 
 | ||||
|     @property | ||||
|     def url(self) -> str: | ||||
|         """ | ||||
|         Combined DAV URL. | ||||
|         """ | ||||
| 
 | ||||
|         return f"{self.protocol}://{self.host}{self.path}{self.prefix}" | ||||
| 
 | ||||
| 
 | ||||
| class RedisSettings(BaseModel): | ||||
|     """ | ||||
|     Connection to a redis server. | ||||
|     """ | ||||
| 
 | ||||
|     host: str = "localhost" | ||||
|     port: int = 6379 | ||||
|     db: int = 0 | ||||
|     protocol: int = 3 | ||||
| 
 | ||||
| 
 | ||||
| class Settings(BaseSettings): | ||||
|     """ | ||||
|     Per-run settings. | ||||
|     """ | ||||
| 
 | ||||
|     model_config = SettingsConfigDict( | ||||
|         env_file="api.conf", | ||||
|         env_file_encoding="utf-8", | ||||
|         env_nested_delimiter="__", | ||||
|     ) | ||||
| 
 | ||||
|     ##### | ||||
|     # general settings | ||||
|     ##### | ||||
| 
 | ||||
|     production_mode: bool = False | ||||
|     ui_directory: str = "/usr/local/share/advent22_ui/html" | ||||
| 
 | ||||
|     ##### | ||||
|     # openapi settings | ||||
|     ##### | ||||
| 
 | ||||
|     def __dev_value(self, value: T) -> T | None: | ||||
|         if self.production_mode: | ||||
|             return None | ||||
| 
 | ||||
|         return value | ||||
| 
 | ||||
|     @property | ||||
|     def openapi_url(self) -> str | None: | ||||
|         return self.__dev_value("/api/openapi.json") | ||||
| 
 | ||||
|     @property | ||||
|     def docs_url(self) -> str | None: | ||||
|         return self.__dev_value("/api/docs") | ||||
| 
 | ||||
|     @property | ||||
|     def redoc_url(self) -> str | None: | ||||
|         return self.__dev_value("/api/redoc") | ||||
| 
 | ||||
|     ##### | ||||
|     # webdav settings | ||||
|     ##### | ||||
| 
 | ||||
|     webdav: DavSettings = DavSettings() | ||||
|     redis: RedisSettings = RedisSettings() | ||||
| 
 | ||||
| 
 | ||||
| SETTINGS = Settings() | ||||
|  | @ -1,96 +0,0 @@ | |||
| import re | ||||
| from enum import Enum | ||||
| from random import Random | ||||
| 
 | ||||
| from pydantic import BaseModel, field_validator | ||||
| 
 | ||||
| RE_WHITESPACE = re.compile( | ||||
|     pattern=r"\s+", | ||||
|     flags=re.UNICODE | re.IGNORECASE, | ||||
| ) | ||||
| RE_SPECIAL_CHARS = re.compile( | ||||
|     pattern=r"[^a-zA-Z0-9\s]+", | ||||
|     flags=re.UNICODE | re.IGNORECASE, | ||||
| ) | ||||
| 
 | ||||
| 
 | ||||
| class TransformedString(BaseModel): | ||||
|     class __Whitespace(str, Enum): | ||||
|         # unverändert | ||||
|         KEEP = "KEEP" | ||||
| 
 | ||||
|         # Leerzeichen an Anfang und Ende entfernen | ||||
|         STRIP = "STRIP" | ||||
| 
 | ||||
|         # whitespace durch Leerzeichen ersetzen | ||||
|         SPACE = "SPACE" | ||||
| 
 | ||||
|         # whitespace entfernen | ||||
|         REMOVE = "REMOVE" | ||||
| 
 | ||||
|     class __SpecialChars(str, Enum): | ||||
|         # unverändert | ||||
|         KEEP = "KEEP" | ||||
| 
 | ||||
|         # Sonderzeichen entfernen | ||||
|         REMOVE = "REMOVE" | ||||
| 
 | ||||
|     class __Case(str, Enum): | ||||
|         # unverändert | ||||
|         KEEP = "KEEP" | ||||
| 
 | ||||
|         # GROSSBUCHSTABEN | ||||
|         UPPER = "UPPER" | ||||
| 
 | ||||
|         # kleinbuchstaben | ||||
|         LOWER = "LOWER" | ||||
| 
 | ||||
|         # ZuFÄllIg | ||||
|         RANDOM = "RANDOM" | ||||
| 
 | ||||
|     value: str | ||||
| 
 | ||||
|     whitespace: __Whitespace = __Whitespace.REMOVE | ||||
|     special_chars: __SpecialChars = __SpecialChars.REMOVE | ||||
|     case: __Case = __Case.UPPER | ||||
| 
 | ||||
|     @field_validator("whitespace", "case", mode="before") | ||||
|     def transform_from_str(cls, v) -> str: | ||||
|         return str(v).upper() | ||||
| 
 | ||||
|     @property | ||||
|     def clean(self) -> str: | ||||
|         result = self.value | ||||
| 
 | ||||
|         # Whitespace verarbeiten | ||||
|         if self.whitespace is TransformedString.__Whitespace.STRIP: | ||||
|             result = result.strip() | ||||
| 
 | ||||
|         elif self.whitespace is TransformedString.__Whitespace.SPACE: | ||||
|             result = RE_WHITESPACE.sub(string=result, repl=" ") | ||||
| 
 | ||||
|         elif self.whitespace is TransformedString.__Whitespace.REMOVE: | ||||
|             result = RE_WHITESPACE.sub(string=result, repl="") | ||||
| 
 | ||||
|         # Sonderzeichen verarbeiten | ||||
|         if self.special_chars is TransformedString.__SpecialChars.REMOVE: | ||||
|             result = RE_SPECIAL_CHARS.sub(string=result, repl="") | ||||
| 
 | ||||
|         # Groß-/Kleinschreibung verarbeiten | ||||
|         if self.case is TransformedString.__Case.UPPER: | ||||
|             result = result.upper() | ||||
| 
 | ||||
|         elif self.case is TransformedString.__Case.LOWER: | ||||
|             result = result.lower() | ||||
| 
 | ||||
|         elif self.case is TransformedString.__Case.RANDOM: | ||||
|             rnd = Random(self.value) | ||||
| 
 | ||||
|             def randomcase(c: str) -> str: | ||||
|                 if rnd.choice((True, False)): | ||||
|                     return c.upper() | ||||
|                 return c.lower() | ||||
| 
 | ||||
|             result = "".join(randomcase(c) for c in result) | ||||
| 
 | ||||
|         return result | ||||
|  | @ -1,22 +0,0 @@ | |||
| #!/usr/bin/python3 | ||||
| 
 | ||||
| import uvicorn | ||||
| 
 | ||||
| from .core.settings import SETTINGS | ||||
| 
 | ||||
| 
 | ||||
| def main() -> None: | ||||
|     """ | ||||
|     If the `main` script is run, `uvicorn` is used to run the app. | ||||
|     """ | ||||
| 
 | ||||
|     uvicorn.run( | ||||
|         app="advent22_api.app:app", | ||||
|         host="0.0.0.0", | ||||
|         port=8000, | ||||
|         reload=not SETTINGS.production_mode, | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| if __name__ == "__main__": | ||||
|     main() | ||||
|  | @ -1,8 +0,0 @@ | |||
| from fastapi import APIRouter | ||||
| 
 | ||||
| from . import admin, user | ||||
| 
 | ||||
| router = APIRouter(prefix="/api") | ||||
| 
 | ||||
| router.include_router(admin.router) | ||||
| router.include_router(user.router) | ||||
|  | @ -1,65 +0,0 @@ | |||
| import secrets | ||||
| from datetime import date | ||||
| 
 | ||||
| from fastapi import Depends, HTTPException, status | ||||
| from fastapi.security import HTTPBasic, HTTPBasicCredentials | ||||
| 
 | ||||
| from ..core.config import Config, get_config | ||||
| from ..core.depends import get_all_event_dates | ||||
| from ..core.helpers import EventDates | ||||
| 
 | ||||
| security = HTTPBasic() | ||||
| 
 | ||||
| 
 | ||||
| async def user_is_admin( | ||||
|     credentials: HTTPBasicCredentials = Depends(security), | ||||
|     cfg: Config = Depends(get_config), | ||||
| ) -> bool: | ||||
|     """ | ||||
|     True iff der user "admin" ist | ||||
|     """ | ||||
| 
 | ||||
|     username_correct = secrets.compare_digest( | ||||
|         credentials.username.lower(), | ||||
|         cfg.admin.name.lower(), | ||||
|     ) | ||||
|     password_correct = secrets.compare_digest( | ||||
|         credentials.password, | ||||
|         cfg.admin.password, | ||||
|     ) | ||||
| 
 | ||||
|     return username_correct and password_correct | ||||
| 
 | ||||
| 
 | ||||
| async def require_admin( | ||||
|     is_admin: bool = Depends(user_is_admin), | ||||
| ) -> None: | ||||
|     """ | ||||
|     HTTP 401 iff der user nicht "admin" ist | ||||
|     """ | ||||
| 
 | ||||
|     if not is_admin: | ||||
|         raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wie unhöflich!!!") | ||||
| 
 | ||||
| 
 | ||||
| async def user_visible_days( | ||||
|     event_dates: EventDates = Depends(get_all_event_dates), | ||||
| ) -> list[int]: | ||||
|     """ | ||||
|     User-sichtbare Türchen | ||||
|     """ | ||||
| 
 | ||||
|     today = date.today() | ||||
| 
 | ||||
|     return [event for event, date in event_dates.dates.items() if date <= today] | ||||
| 
 | ||||
| 
 | ||||
| async def user_can_view_day( | ||||
|     day: int, | ||||
|     visible_days: list[int] = Depends(user_visible_days), | ||||
| ) -> bool: | ||||
|     """ | ||||
|     True iff das Türchen von Tag `day` user-sichtbar ist | ||||
|     """ | ||||
| 
 | ||||
|     return day in visible_days | ||||
|  | @ -1,193 +0,0 @@ | |||
| from datetime import date | ||||
| 
 | ||||
| from fastapi import APIRouter, Depends | ||||
| from pydantic import BaseModel | ||||
| 
 | ||||
| from advent22_api.core.helpers import EventDates | ||||
| 
 | ||||
| from ..core.calendar_config import CalendarConfig, DoorsSaved, get_calendar_config | ||||
| from ..core.config import Config, Image, get_config | ||||
| from ..core.depends import ( | ||||
|     TTFont, | ||||
|     get_all_event_dates, | ||||
|     get_all_image_names, | ||||
|     get_all_parts, | ||||
|     get_all_ttfonts, | ||||
| ) | ||||
| from ..core.settings import SETTINGS, RedisSettings | ||||
| from ._security import require_admin, user_is_admin | ||||
| 
 | ||||
| router = APIRouter(prefix="/admin", tags=["admin"]) | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/is_admin") | ||||
| async def is_admin( | ||||
|     is_admin: bool = Depends(user_is_admin), | ||||
| ) -> bool: | ||||
|     return is_admin | ||||
| 
 | ||||
| 
 | ||||
| class AdminConfigModel(BaseModel): | ||||
|     class __Solution(BaseModel): | ||||
|         value: str | ||||
|         whitespace: str | ||||
|         special_chars: str | ||||
|         case: str | ||||
|         clean: str | ||||
| 
 | ||||
|     class __Puzzle(BaseModel): | ||||
|         first: date | ||||
|         next: date | None | ||||
|         last: date | ||||
|         end: date | ||||
|         seed: str | ||||
|         extra_days: list[int] | ||||
|         skip_empty: bool | ||||
| 
 | ||||
|     class __Calendar(BaseModel): | ||||
|         config_file: str | ||||
|         background: str | ||||
|         favicon: str | ||||
| 
 | ||||
|     class __Font(BaseModel): | ||||
|         file: str | ||||
|         size: int | ||||
| 
 | ||||
|     class __WebDAV(BaseModel): | ||||
|         url: str | ||||
|         cache_ttl: int | ||||
|         config_file: str | ||||
| 
 | ||||
|     solution: __Solution | ||||
|     puzzle: __Puzzle | ||||
|     calendar: __Calendar | ||||
|     image: Image | ||||
|     fonts: list[__Font] | ||||
|     redis: RedisSettings | ||||
|     webdav: __WebDAV | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/config_model") | ||||
| async def get_config_model( | ||||
|     _: None = Depends(require_admin), | ||||
|     cfg: Config = Depends(get_config), | ||||
|     cal_cfg: CalendarConfig = Depends(get_calendar_config), | ||||
|     event_dates: EventDates = Depends(get_all_event_dates), | ||||
|     ttfonts: list[TTFont] = Depends(get_all_ttfonts), | ||||
| ) -> AdminConfigModel: | ||||
|     """ | ||||
|     Kombiniert aus privaten `settings`, `config` und `calendar_config` | ||||
|     """ | ||||
| 
 | ||||
|     return AdminConfigModel.model_validate( | ||||
|         { | ||||
|             "solution": { | ||||
|                 "value": cfg.solution.value, | ||||
|                 "whitespace": cfg.solution.whitespace, | ||||
|                 "special_chars": cfg.solution.special_chars, | ||||
|                 "case": cfg.solution.case, | ||||
|                 "clean": cfg.solution.clean, | ||||
|             }, | ||||
|             "puzzle": { | ||||
|                 "first": event_dates.first, | ||||
|                 "next": event_dates.next, | ||||
|                 "last": event_dates.last, | ||||
|                 "end": event_dates.end, | ||||
|                 "seed": cfg.random_seed, | ||||
|                 "extra_days": sorted(cfg.puzzle.extra_days), | ||||
|                 "skip_empty": cfg.puzzle.skip_empty, | ||||
|             }, | ||||
|             "calendar": { | ||||
|                 "config_file": cfg.calendar, | ||||
|                 "background": cal_cfg.background, | ||||
|                 "favicon": cal_cfg.favicon, | ||||
|             }, | ||||
|             "image": cfg.image, | ||||
|             "fonts": [ | ||||
|                 {"file": ttfont.file_name, "size": ttfont.size} for ttfont in ttfonts | ||||
|             ], | ||||
|             "redis": SETTINGS.redis, | ||||
|             "webdav": { | ||||
|                 "url": SETTINGS.webdav.url, | ||||
|                 "cache_ttl": SETTINGS.webdav.cache_ttl, | ||||
|                 "config_file": SETTINGS.webdav.config_filename, | ||||
|             }, | ||||
|         } | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/day_image_names") | ||||
| async def get_day_image_names( | ||||
|     _: None = Depends(require_admin), | ||||
|     image_names: dict[int, str] = Depends(get_all_image_names), | ||||
| ) -> dict[int, str]: | ||||
|     """ | ||||
|     Zuordnung der verwendeten Bilder zu den Tagen | ||||
|     """ | ||||
| 
 | ||||
|     return image_names | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/day_parts") | ||||
| async def get_day_parts( | ||||
|     _: None = Depends(require_admin), | ||||
|     parts: dict[int, str] = Depends(get_all_parts), | ||||
| ) -> dict[int, str]: | ||||
|     """ | ||||
|     Zuordnung der Lösungsteile zu den Tagen | ||||
|     """ | ||||
| 
 | ||||
|     return parts | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/doors") | ||||
| async def get_doors( | ||||
|     _: None = Depends(require_admin), | ||||
|     cal_cfg: CalendarConfig = Depends(get_calendar_config), | ||||
| ) -> DoorsSaved: | ||||
|     """ | ||||
|     Türchen lesen | ||||
|     """ | ||||
| 
 | ||||
|     return cal_cfg.doors | ||||
| 
 | ||||
| 
 | ||||
| @router.put("/doors") | ||||
| async def put_doors( | ||||
|     doors: DoorsSaved, | ||||
|     _: None = Depends(require_admin), | ||||
|     cfg: Config = Depends(get_config), | ||||
|     cal_cfg: CalendarConfig = Depends(get_calendar_config), | ||||
| ) -> None: | ||||
|     """ | ||||
|     Türchen ändern | ||||
|     """ | ||||
| 
 | ||||
|     cal_cfg.doors = sorted( | ||||
|         doors, | ||||
|         key=lambda door: door.day, | ||||
|     ) | ||||
|     await cal_cfg.change(cfg) | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/dav_credentials") | ||||
| async def get_dav_credentials( | ||||
|     _: None = Depends(require_admin), | ||||
| ) -> tuple[str, str]: | ||||
|     """ | ||||
|     Zugangsdaten für WebDAV | ||||
|     """ | ||||
| 
 | ||||
|     return SETTINGS.webdav.username, SETTINGS.webdav.password | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/ui_credentials") | ||||
| async def get_ui_credentials( | ||||
|     _: None = Depends(require_admin), | ||||
|     cfg: Config = Depends(get_config), | ||||
| ) -> tuple[str, str]: | ||||
|     """ | ||||
|     Zugangsdaten für Admin-UI | ||||
|     """ | ||||
| 
 | ||||
|     return cfg.admin.name, cfg.admin.password | ||||
|  | @ -1,109 +0,0 @@ | |||
| from datetime import datetime | ||||
| 
 | ||||
| from fastapi import APIRouter, Depends, HTTPException, status | ||||
| from fastapi.responses import StreamingResponse | ||||
| from PIL import Image | ||||
| 
 | ||||
| from ..core.calendar_config import CalendarConfig, DoorsSaved, get_calendar_config | ||||
| from ..core.config import Config, Site, get_config | ||||
| from ..core.depends import get_all_event_dates, get_day_image | ||||
| from ..core.helpers import EventDates, api_return_ico, api_return_jpeg, load_image | ||||
| from ._security import user_can_view_day, user_is_admin, user_visible_days | ||||
| 
 | ||||
| router = APIRouter(prefix="/user", tags=["user"]) | ||||
| 
 | ||||
| 
 | ||||
| @router.get( | ||||
|     "/background_image", | ||||
|     response_class=StreamingResponse, | ||||
| ) | ||||
| async def get_background_image( | ||||
|     cal_cfg: CalendarConfig = Depends(get_calendar_config), | ||||
| ) -> StreamingResponse: | ||||
|     """ | ||||
|     Hintergrundbild laden | ||||
|     """ | ||||
| 
 | ||||
|     return await api_return_jpeg(await load_image(f"files/{cal_cfg.background}")) | ||||
| 
 | ||||
| 
 | ||||
| @router.get( | ||||
|     "/favicon", | ||||
|     response_class=StreamingResponse, | ||||
| ) | ||||
| async def get_favicon( | ||||
|     cal_cfg: CalendarConfig = Depends(get_calendar_config), | ||||
| ) -> StreamingResponse: | ||||
|     """ | ||||
|     Favicon laden | ||||
|     """ | ||||
| 
 | ||||
|     try: | ||||
|         return await api_return_ico(await load_image(f"files/{cal_cfg.favicon}")) | ||||
| 
 | ||||
|     except RuntimeError: | ||||
|         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/site_config") | ||||
| async def get_site_config( | ||||
|     cfg: Config = Depends(get_config), | ||||
| ) -> Site: | ||||
|     """ | ||||
|     Seiteninhalt | ||||
|     """ | ||||
| 
 | ||||
|     return cfg.site | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/doors") | ||||
| async def get_doors( | ||||
|     cal_cfg: CalendarConfig = Depends(get_calendar_config), | ||||
|     visible_days: list[int] = Depends(user_visible_days), | ||||
| ) -> DoorsSaved: | ||||
|     """ | ||||
|     User-sichtbare Türchen lesen | ||||
|     """ | ||||
| 
 | ||||
|     return [door for door in cal_cfg.doors if door.day in visible_days] | ||||
| 
 | ||||
| 
 | ||||
| @router.get( | ||||
|     "/image_{day}", | ||||
|     response_class=StreamingResponse, | ||||
| ) | ||||
| async def get_image_for_day( | ||||
|     user_can_view: bool = Depends(user_can_view_day), | ||||
|     is_admin: bool = Depends(user_is_admin), | ||||
|     image: Image.Image | None = Depends(get_day_image), | ||||
| ) -> StreamingResponse: | ||||
|     """ | ||||
|     Bild für einen Tag erstellen | ||||
|     """ | ||||
| 
 | ||||
|     if not (user_can_view or is_admin): | ||||
|         raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wie unhöflich!!!") | ||||
| 
 | ||||
|     if image is None: | ||||
|         raise HTTPException( | ||||
|             status.HTTP_404_NOT_FOUND, "Ich habe heute leider kein Foto für dich." | ||||
|         ) | ||||
| 
 | ||||
|     return await api_return_jpeg(image) | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/next_door") | ||||
| async def get_next_door( | ||||
|     event_dates: EventDates = Depends(get_all_event_dates), | ||||
| ) -> int | None: | ||||
|     """ | ||||
|     Zeit in ms, bis das nächste Türchen öffnet | ||||
|     """ | ||||
| 
 | ||||
|     if event_dates.next is None: | ||||
|         return None | ||||
| 
 | ||||
|     dt = datetime.combine(event_dates.next, datetime.min.time()) | ||||
|     td = dt - datetime.now() | ||||
| 
 | ||||
|     return int(td.total_seconds() * 1000) | ||||
							
								
								
									
										1475
									
								
								api/poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1475
									
								
								api/poetry.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -1,33 +0,0 @@ | |||
| [tool.poetry] | ||||
| authors = [ | ||||
|   "Jörn-Michael Miehe <jmm@yavook.de>", | ||||
|   "Penner42 <unbekannt42@web.de>", | ||||
| ] | ||||
| description = "" | ||||
| license = "MIT" | ||||
| name = "advent22_api" | ||||
| version = "0.1.0" | ||||
| 
 | ||||
| [tool.poetry.dependencies] | ||||
| Pillow = "^10.2.0" | ||||
| asyncify = "^0.9.2" | ||||
| cachetools = "^5.3.3" | ||||
| cachetoolsutils = "^8.5" | ||||
| fastapi = "^0.103.1" | ||||
| markdown = "^3.6" | ||||
| numpy = "^1.26.4" | ||||
| pydantic-settings = "^2.2.1" | ||||
| python = ">=3.11,<3.13" | ||||
| redis = {extras = ["hiredis"], version = "^5.0.3"} | ||||
| tomli-w = "^1.0.0" | ||||
| uvicorn = {extras = ["standard"], version = "^0.23.2"} | ||||
| webdavclient3 = "^3.14.6" | ||||
| 
 | ||||
| [tool.poetry.group.dev.dependencies] | ||||
| black = "^24.3.0" | ||||
| flake8 = "^7.0.0" | ||||
| pytest = "^8.1.1" | ||||
| 
 | ||||
| [build-system] | ||||
| build-backend = "poetry.core.masonry.api" | ||||
| requires = ["poetry-core>=1.0.0"] | ||||
|  | @ -1,88 +0,0 @@ | |||
| from datetime import date | ||||
| 
 | ||||
| from advent22_api.core.helpers import EventDates | ||||
| 
 | ||||
| 
 | ||||
| def test_get_before(): | ||||
|     today = date(2023, 11, 30) | ||||
| 
 | ||||
|     ed = EventDates( | ||||
|         today=today, | ||||
|         begin_month=12, | ||||
|         begin_day=1, | ||||
|         events=list(range(1, 25)), | ||||
|         close_after=5, | ||||
|     ) | ||||
| 
 | ||||
|     assert ed.first == date(2023, 12, 1) | ||||
|     assert ed.get_next(today=today) == date(2023, 12, 1) | ||||
|     assert ed.last == date(2023, 12, 24) | ||||
|     assert ed.end == date(2023, 12, 29) | ||||
| 
 | ||||
| 
 | ||||
| def test_get_after(): | ||||
|     today = date(2023, 12, 30) | ||||
| 
 | ||||
|     ed = EventDates( | ||||
|         today=today, | ||||
|         begin_month=12, | ||||
|         begin_day=1, | ||||
|         events=list(range(1, 25)), | ||||
|         close_after=5, | ||||
|     ) | ||||
| 
 | ||||
|     assert ed.first == date(2024, 12, 1) | ||||
|     assert ed.get_next(today=today) == date(2024, 12, 1) | ||||
|     assert ed.last == date(2024, 12, 24) | ||||
|     assert ed.end == date(2024, 12, 29) | ||||
| 
 | ||||
| 
 | ||||
| def test_get_during_events(): | ||||
|     today = date(2023, 12, 10) | ||||
| 
 | ||||
|     ed = EventDates( | ||||
|         today=today, | ||||
|         begin_month=12, | ||||
|         begin_day=1, | ||||
|         events=list(range(1, 25)), | ||||
|         close_after=5, | ||||
|     ) | ||||
| 
 | ||||
|     assert ed.first == date(2023, 12, 1) | ||||
|     assert ed.get_next(today=today) == date(2023, 12, 11) | ||||
|     assert ed.last == date(2023, 12, 24) | ||||
|     assert ed.end == date(2023, 12, 29) | ||||
| 
 | ||||
| 
 | ||||
| def test_get_during_closing(): | ||||
|     today = date(2023, 12, 29) | ||||
| 
 | ||||
|     ed = EventDates( | ||||
|         today=today, | ||||
|         begin_month=12, | ||||
|         begin_day=1, | ||||
|         events=list(range(1, 25)), | ||||
|         close_after=5, | ||||
|     ) | ||||
| 
 | ||||
|     assert ed.first == date(2023, 12, 1) | ||||
|     assert ed.get_next(today=today) is None | ||||
|     assert ed.last == date(2023, 12, 24) | ||||
|     assert ed.end == date(2023, 12, 29) | ||||
| 
 | ||||
| 
 | ||||
| def test_get_during_wrap(): | ||||
|     today = date(2024, 1, 1) | ||||
| 
 | ||||
|     ed = EventDates( | ||||
|         today=today, | ||||
|         begin_month=12, | ||||
|         begin_day=1, | ||||
|         events=list(range(1, 25)), | ||||
|         close_after=8, | ||||
|     ) | ||||
| 
 | ||||
|     assert ed.first == date(2023, 12, 1) | ||||
|     assert ed.get_next(today=today) is None | ||||
|     assert ed.last == date(2023, 12, 24) | ||||
|     assert ed.end == date(2024, 1, 1) | ||||
|  | @ -1,77 +0,0 @@ | |||
| from advent22_api.core.helpers import spread | ||||
| 
 | ||||
| 
 | ||||
| def test_easy() -> None: | ||||
|     assert spread([1, 4], 0) == [] | ||||
|     assert spread([1, 4], 1) == [2] | ||||
|     assert spread([1, 4], 2) == [2, 3] | ||||
|     assert spread([1, 4], 5) == [2, 3, 1, 2, 3] | ||||
|     assert spread([1, 4], 10) == [2, 3, 1, 2, 3, 4, 1, 2, 3, 4] | ||||
| 
 | ||||
| 
 | ||||
| def test_tight() -> None: | ||||
|     assert spread([1, 2], 0) == [] | ||||
|     assert spread([1, 2], 1) == [1] | ||||
|     assert spread([1, 2], 2) == [1, 2] | ||||
|     assert spread([1, 2], 5) == [1, 2, 1, 2, 1] | ||||
|     assert spread([1, 2], 10) == [1, 2, 1, 2, 1, 2, 1, 2, 1, 2] | ||||
| 
 | ||||
|     assert spread([1, 2, 3, 4, 5], 0) == [] | ||||
|     assert spread([1, 2, 3, 4, 5], 1) == [1] | ||||
|     assert spread([1, 2, 3, 4, 5], 2) == [1, 2] | ||||
|     assert spread([1, 2, 3, 4, 5], 5) == [1, 2, 3, 4, 5] | ||||
|     assert spread([1, 2, 3, 4, 5], 10) == [1, 2, 3, 4, 5, 1, 2, 3, 4, 5] | ||||
| 
 | ||||
| 
 | ||||
| def test_more_given() -> None: | ||||
|     assert spread([0, 5, 10], 0) == [] | ||||
|     assert spread([0, 5, 10], 1) == [1] | ||||
|     assert spread([0, 5, 10], 2) == [1, 2] | ||||
|     assert spread([0, 5, 10], 5) == [1, 2, 3, 4, 6] | ||||
|     assert spread([0, 5, 10], 10) == [1, 2, 3, 4, 6, 7, 8, 9, 0, 1] | ||||
| 
 | ||||
|     assert spread([0, 1, 2, 5, 10], 0) == [] | ||||
|     assert spread([0, 1, 2, 5, 10], 1) == [3] | ||||
|     assert spread([0, 1, 2, 5, 10], 2) == [3, 4] | ||||
|     assert spread([0, 1, 2, 5, 10], 5) == [3, 4, 6, 7, 8] | ||||
|     assert spread([0, 1, 2, 5, 10], 10) == [3, 4, 6, 7, 8, 9, 0, 1, 2, 3] | ||||
| 
 | ||||
| 
 | ||||
| def test_one_given() -> None: | ||||
|     assert spread([0], 0) == [] | ||||
|     assert spread([0], 1) == [1] | ||||
|     assert spread([0], 2) == [1, 2] | ||||
|     assert spread([0], 5) == [1, 2, 3, 4, 5] | ||||
|     assert spread([0], 10) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] | ||||
| 
 | ||||
|     assert spread([1], 0) == [] | ||||
|     assert spread([1], 1) == [1] | ||||
|     assert spread([1], 2) == [1, 1] | ||||
|     assert spread([1], 5) == [1, 1, 1, 1, 1] | ||||
|     assert spread([1], 10) == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] | ||||
| 
 | ||||
|     assert spread([2], 0) == [] | ||||
|     assert spread([2], 1) == [1] | ||||
|     assert spread([2], 2) == [1, 1] | ||||
|     assert spread([2], 5) == [1, 1, 2, 1, 2] | ||||
|     assert spread([2], 10) == [1, 1, 2, 1, 2, 1, 2, 1, 2, 1] | ||||
| 
 | ||||
|     assert spread([5], 0) == [] | ||||
|     assert spread([5], 1) == [1] | ||||
|     assert spread([5], 2) == [1, 2] | ||||
|     assert spread([5], 5) == [1, 2, 3, 4, 1] | ||||
|     assert spread([5], 10) == [1, 2, 3, 4, 1, 2, 3, 4, 5, 1] | ||||
| 
 | ||||
|     assert spread([10], 0) == [] | ||||
|     assert spread([10], 1) == [1] | ||||
|     assert spread([10], 2) == [1, 2] | ||||
|     assert spread([10], 5) == [1, 2, 3, 4, 5] | ||||
|     assert spread([10], 10) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 1] | ||||
| 
 | ||||
| 
 | ||||
| def test_none_given() -> None: | ||||
|     assert spread([], 0) == [] | ||||
|     assert spread([], 1) == [1] | ||||
|     assert spread([], 2) == [1, 2] | ||||
|     assert spread([], 5) == [1, 2, 3, 4, 5] | ||||
|     assert spread([], 10) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] | ||||
|  | @ -1,4 +0,0 @@ | |||
| > 1% | ||||
| last 2 versions | ||||
| not dead | ||||
| not ie 11 | ||||
|  | @ -1,39 +0,0 @@ | |||
| // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: | ||||
| // https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/javascript-node | ||||
| { | ||||
| 	"name": "Advent22 UI", | ||||
| 	// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile | ||||
| 	"image": "mcr.microsoft.com/vscode/devcontainers/javascript-node:1-18-bookworm", | ||||
| 	// Features to add to the dev container. More info: https://containers.dev/features. | ||||
| 	"features": { | ||||
| 		"ghcr.io/devcontainers-contrib/features/apt-get-packages:1": { | ||||
| 			"packages": "git-flow, git-lfs" | ||||
| 		}, | ||||
| 		"ghcr.io/devcontainers-contrib/features/vue-cli:2": {} | ||||
| 	}, | ||||
| 	// Configure tool-specific properties. | ||||
| 	"customizations": { | ||||
| 		// Configure properties specific to VS Code. | ||||
| 		"vscode": { | ||||
| 			// Set *default* container specific settings.json values on container create. | ||||
| 			"settings": { | ||||
| 				"terminal.integrated.defaultProfile.linux": "zsh" | ||||
| 			}, | ||||
| 			// Add the IDs of extensions you want installed when the container is created. | ||||
| 			"extensions": [ | ||||
| 				"dbaeumer.vscode-eslint", | ||||
| 				"esbenp.prettier-vscode", | ||||
| 				"mhutchie.git-graph", | ||||
| 				"Syler.sass-indented", | ||||
| 				"Vue.volar" | ||||
| 			] | ||||
| 		} | ||||
| 	}, | ||||
| 	// Use 'forwardPorts' to make a list of ports inside the container available locally. | ||||
| 	// "forwardPorts": [], | ||||
| 	// Use 'postCreateCommand' to run commands after the container is created. | ||||
| 	// "postCreateCommand": "yarn install", | ||||
| 	"postStartCommand": "yarn install --production false", | ||||
| 	// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. | ||||
| 	"remoteUser": "node" | ||||
| } | ||||
|  | @ -1,34 +0,0 @@ | |||
| module.exports = { | ||||
|   root: true, | ||||
| 
 | ||||
|   env: { | ||||
|     node: true | ||||
|   }, | ||||
| 
 | ||||
|   'extends': [ | ||||
|     'plugin:vue/vue3-essential', | ||||
|     'eslint:recommended', | ||||
|     '@vue/typescript/recommended' | ||||
|   ], | ||||
| 
 | ||||
|   parserOptions: { | ||||
|     ecmaVersion: 2020 | ||||
|   }, | ||||
| 
 | ||||
|   rules: { | ||||
|     'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', | ||||
|     'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' | ||||
|   }, | ||||
| 
 | ||||
|   overrides: [ | ||||
|     { | ||||
|       files: [ | ||||
|         '**/__tests__/*.{j,t}s?(x)', | ||||
|         '**/tests/unit/**/*.spec.{j,t}s?(x)' | ||||
|       ], | ||||
|       env: { | ||||
|         mocha: true | ||||
|       } | ||||
|     } | ||||
|   ] | ||||
| } | ||||
							
								
								
									
										23
									
								
								ui/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								ui/.gitignore
									
									
									
									
										vendored
									
									
								
							|  | @ -1,23 +0,0 @@ | |||
| .DS_Store | ||||
| node_modules | ||||
| /dist | ||||
| 
 | ||||
| 
 | ||||
| # local env files | ||||
| .env.local | ||||
| .env.*.local | ||||
| 
 | ||||
| # Log files | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| pnpm-debug.log* | ||||
| 
 | ||||
| # Editor directories and files | ||||
| .idea | ||||
| # .vscode | ||||
| *.suo | ||||
| *.ntvs* | ||||
| *.njsproj | ||||
| *.sln | ||||
| *.sw? | ||||
							
								
								
									
										5
									
								
								ui/.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								ui/.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							|  | @ -1,5 +0,0 @@ | |||
| { | ||||
|   "recommendations": [ | ||||
|     "sdras.vue-vscode-snippets" | ||||
|   ] | ||||
| } | ||||
							
								
								
									
										15
									
								
								ui/.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								ui/.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							|  | @ -1,15 +0,0 @@ | |||
| { | ||||
|   // Verwendet IntelliSense zum Ermitteln möglicher Attribute. | ||||
|   // Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen. | ||||
|   // Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387 | ||||
|   "version": "0.2.0", | ||||
|   "configurations": [ | ||||
|     { | ||||
|       "type": "chrome", | ||||
|       "request": "launch", | ||||
|       "name": "Chrome mit Advent22 UI starten", | ||||
|       "url": "http://localhost:8080", | ||||
|       "webRoot": "${workspaceFolder}" | ||||
|     } | ||||
|   ] | ||||
| } | ||||
							
								
								
									
										22
									
								
								ui/.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								ui/.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							|  | @ -1,22 +0,0 @@ | |||
| { | ||||
|   "editor.formatOnSave": true, | ||||
|   "[vue]": { | ||||
|     "editor.defaultFormatter": "esbenp.prettier-vscode" | ||||
|   }, | ||||
|   "[typescript]": { | ||||
|     "editor.defaultFormatter": "esbenp.prettier-vscode" | ||||
|   }, | ||||
|   "[javascript]": { | ||||
|     "editor.defaultFormatter": "esbenp.prettier-vscode" | ||||
|   }, | ||||
|   "editor.codeActionsOnSave": { | ||||
|     "source.organizeImports": "explicit" | ||||
|   }, | ||||
|   "git.closeDiffOnOperation": true, | ||||
|   "editor.tabSize": 2, | ||||
|   "sass.disableAutoIndent": true, | ||||
|   "sass.format.convert": false, | ||||
|   "sass.format.deleteWhitespace": true, | ||||
|   "prettier.trailingComma": "all", | ||||
|   "volar.inlayHints.eventArgumentInInlineHandlers": false, | ||||
| } | ||||
							
								
								
									
										12
									
								
								ui/.vscode/tasks.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								ui/.vscode/tasks.json
									
									
									
									
										vendored
									
									
								
							|  | @ -1,12 +0,0 @@ | |||
| { | ||||
| 	"version": "2.0.0", | ||||
| 	"tasks": [ | ||||
| 		{ | ||||
| 			"type": "npm", | ||||
| 			"script": "serve", | ||||
| 			"problemMatcher": [], | ||||
| 			"label": "UI starten", | ||||
| 			"detail": "vue-cli-service serve" | ||||
| 		} | ||||
| 	] | ||||
| } | ||||
							
								
								
									
										24
									
								
								ui/README.md
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								ui/README.md
									
									
									
									
									
								
							|  | @ -1,24 +0,0 @@ | |||
| # advent22_ui | ||||
| 
 | ||||
| ## Project setup | ||||
| ``` | ||||
| yarn install | ||||
| ``` | ||||
| 
 | ||||
| ### Compiles and hot-reloads for development | ||||
| ``` | ||||
| yarn serve | ||||
| ``` | ||||
| 
 | ||||
| ### Compiles and minifies for production | ||||
| ``` | ||||
| yarn build | ||||
| ``` | ||||
| 
 | ||||
| ### Lints and fixes files | ||||
| ``` | ||||
| yarn lint | ||||
| ``` | ||||
| 
 | ||||
| ### Customize configuration | ||||
| See [Configuration Reference](https://cli.vuejs.org/config/). | ||||
|  | @ -1,5 +0,0 @@ | |||
| { | ||||
|   "presets": [ | ||||
|     "@vue/cli-plugin-babel/preset" | ||||
|   ] | ||||
| } | ||||
|  | @ -1,47 +0,0 @@ | |||
| { | ||||
|   "name": "advent22_ui", | ||||
|   "version": "0.1.0", | ||||
|   "private": true, | ||||
|   "scripts": { | ||||
|     "serve": "vue-cli-service serve --host 0.0.0.0 --port 8080", | ||||
|     "build": "vue-cli-service build", | ||||
|     "test:unit": "vue-cli-service test:unit", | ||||
|     "test:unit-watch": "vue-cli-service test:unit --watch", | ||||
|     "lint": "vue-cli-service lint" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@fortawesome/fontawesome-svg-core": "^6.5.1", | ||||
|     "@fortawesome/free-brands-svg-icons": "^6.5.1", | ||||
|     "@fortawesome/free-solid-svg-icons": "^6.5.1", | ||||
|     "@fortawesome/vue-fontawesome": "^3.0.6", | ||||
|     "@types/chai": "^4.3.14", | ||||
|     "@types/luxon": "^3.4.2", | ||||
|     "@types/mocha": "^10.0.6", | ||||
|     "@typescript-eslint/eslint-plugin": "^7.3.1", | ||||
|     "@typescript-eslint/parser": "^7.3.1", | ||||
|     "@vue/cli-plugin-babel": "~5.0.0", | ||||
|     "@vue/cli-plugin-eslint": "~5.0.0", | ||||
|     "@vue/cli-plugin-typescript": "~5.0.0", | ||||
|     "@vue/cli-plugin-unit-mocha": "~5.0.0", | ||||
|     "@vue/cli-service": "~5.0.0", | ||||
|     "@vue/eslint-config-typescript": "^13.0.0", | ||||
|     "@vue/test-utils": "^2.4.5", | ||||
|     "@vueuse/core": "^10.9.0", | ||||
|     "animate.css": "^4.1.1", | ||||
|     "axios": "^1.6.8", | ||||
|     "bulma": "^0.9.4", | ||||
|     "bulma-prefers-dark": "^0.1.0-beta.1", | ||||
|     "bulma-toast": "2.4.3", | ||||
|     "chai": "^4.3.10", | ||||
|     "core-js": "^3.36.1", | ||||
|     "eslint": "^8.57.0", | ||||
|     "eslint-plugin-vue": "^9.23.0", | ||||
|     "luxon": "^3.4.4", | ||||
|     "pinia": "^2.1.7", | ||||
|     "sass": "^1.72.0", | ||||
|     "sass-loader": "^14.1.1", | ||||
|     "typescript": "~5.4.3", | ||||
|     "vue": "^3.4.21", | ||||
|     "vue-class-component": "^8.0.0-0" | ||||
|   } | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 38 KiB | 
|  | @ -1,32 +0,0 @@ | |||
| <!DOCTYPE html> | ||||
| <html lang=""> | ||||
|   <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <meta http-equiv="X-UA-Compatible" content="IE=edge"> | ||||
|     <meta name="viewport" content="width=device-width,initial-scale=1.0"> | ||||
|     <link rel="icon" href="<%= BASE_URL %>favicon.ico"> | ||||
|     <title><%= htmlWebpackPlugin.options.title %></title> | ||||
|     <!-- Matomo --> | ||||
|     <script> | ||||
|       let _paq = window._paq = window._paq || []; | ||||
|       /* tracker methods like "setCustomDimension" should be called before "trackPageView" */ | ||||
|       _paq.push(['trackPageView']); | ||||
|       _paq.push(['enableLinkTracking']); | ||||
|       (function () { | ||||
|         const u = "https://stats.kiwi.lenaisten.de/"; | ||||
|         _paq.push(['setTrackerUrl', u + 'matomo.php']); | ||||
|         _paq.push(['setSiteId', '10']); | ||||
|         const d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0]; | ||||
|         g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s); | ||||
|       })(); | ||||
|     </script> | ||||
|     <!-- End Matomo Code --> | ||||
|   </head> | ||||
|   <body> | ||||
|     <noscript> | ||||
|       <strong>Es tut uns leid, aber <%= htmlWebpackPlugin.options.title %> funktioniert nicht richtig ohne JavaScript. Bitte aktivieren Sie es, um fortzufahren.</strong> | ||||
|     </noscript> | ||||
|     <div id="app"></div> | ||||
|     <!-- built files will be auto injected --> | ||||
|   </body> | ||||
| </html> | ||||
|  | @ -1,67 +0,0 @@ | |||
| <template> | ||||
|   <section class="hero is-small is-primary"> | ||||
|     <div class="hero-body"> | ||||
|       <h1 class="title is-uppercase">{{ store.site_config.title }}</h1> | ||||
|       <h2 class="subtitle">{{ store.site_config.subtitle }}</h2> | ||||
|     </div> | ||||
|   </section> | ||||
| 
 | ||||
|   <section class="section px-3"> | ||||
|     <div class="container"> | ||||
|       <AdminView v-if="store.is_admin" /> | ||||
|       <UserView v-else /> | ||||
|     </div> | ||||
|   </section> | ||||
| 
 | ||||
|   <div class="is-flex-grow-1" /> | ||||
| 
 | ||||
|   <footer class="footer"> | ||||
|     <div class="level"> | ||||
|       <div class="level-item"> | ||||
|         <p v-html="store.site_config.footer" /> | ||||
|       </div> | ||||
|       <div class="level-right"> | ||||
|         <div class="level-item"> | ||||
|           <TouchButton class="tag is-warning" /> | ||||
|         </div> | ||||
|         <div class="level-item"> | ||||
|           <AdminButton class="tag is-link is-outlined" /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </footer> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| import { advent22Store } from "./plugins/store"; | ||||
| 
 | ||||
| import AdminView from "./components/admin/AdminView.vue"; | ||||
| import AdminButton from "./components/AdminButton.vue"; | ||||
| import TouchButton from "./components/TouchButton.vue"; | ||||
| import UserView from "./components/UserView.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     AdminView, | ||||
|     AdminButton, | ||||
|     TouchButton, | ||||
|     UserView, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public readonly store = advent22Store(); | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style> | ||||
| html { | ||||
|   overflow-y: auto !important; | ||||
| } | ||||
| 
 | ||||
| #app { | ||||
|   min-height: 100vh; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										
											BIN
										
									
								
								ui/src/assets/logo.png
									 (Stored with Git LFS)
									
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								ui/src/assets/logo.png
									 (Stored with Git LFS)
									
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							|  | @ -1,22 +0,0 @@ | |||
| @charset "utf-8"; | ||||
| @use "sass:map"; | ||||
| 
 | ||||
| //===================== | ||||
| // custom color scheme | ||||
| //===================== | ||||
| 
 | ||||
| $advent22-colors: ( | ||||
|   "primary": #945DE1, | ||||
|   "link": #64B4BD, | ||||
|   "info": #8C4E80, | ||||
|   "success": #7E8E2B, | ||||
|   "warning": #F6CA6B, | ||||
|   "danger": #C5443B, | ||||
| ); | ||||
| 
 | ||||
| $primary: map.get($advent22-colors, "primary"); | ||||
| $link: map.get($advent22-colors, "link"); | ||||
| $info: map.get($advent22-colors, "info"); | ||||
| $success: map.get($advent22-colors, "success"); | ||||
| $warning: map.get($advent22-colors, "warning"); | ||||
| $danger: map.get($advent22-colors, "danger"); | ||||
|  | @ -1,56 +0,0 @@ | |||
| <template> | ||||
|   <LoginModal v-if="modal_visible" @submit="on_submit" @cancel="on_cancel" /> | ||||
| 
 | ||||
|   <BulmaButton | ||||
|     v-bind="$attrs" | ||||
|     :icon="'fa-solid fa-toggle-' + (store.is_admin ? 'on' : 'off')" | ||||
|     :busy="is_busy" | ||||
|     text="Admin" | ||||
|     @click.left="on_click" | ||||
|   /> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Credentials } from "@/lib/api"; | ||||
| import { advent22Store } from "@/plugins/store"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import BulmaButton from "./bulma/Button.vue"; | ||||
| import LoginModal from "./LoginModal.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     BulmaButton, | ||||
|     LoginModal, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public modal_visible = false; | ||||
|   public is_busy = false; | ||||
|   public readonly store = advent22Store(); | ||||
| 
 | ||||
|   public on_click() { | ||||
|     if (this.store.is_admin) { | ||||
|       this.store.logout(); | ||||
|     } else { | ||||
|       // show login modal | ||||
|       this.is_busy = true; | ||||
|       this.modal_visible = true; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public on_submit(creds: Credentials) { | ||||
|     this.modal_visible = false; | ||||
| 
 | ||||
|     this.store | ||||
|       .login(creds) | ||||
|       .catch(this.store.alert_user_error) | ||||
|       .finally(() => (this.is_busy = false)); | ||||
|   } | ||||
| 
 | ||||
|   public on_cancel() { | ||||
|     this.modal_visible = false; | ||||
|     this.is_busy = false; | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | @ -1,121 +0,0 @@ | |||
| <template> | ||||
|   <MultiModal @handle="modal_handle" /> | ||||
| 
 | ||||
|   <BulmaToast @handle="toast_handle" class="content"> | ||||
|     <p> | ||||
|       Du hast noch keine Türchen geöffnet, vielleicht gibt es ein Anzeigeproblem | ||||
|       in Deinem Webbrowser? | ||||
|     </p> | ||||
|     <div class="level"> | ||||
|       <div class="level-item"> | ||||
|         <BulmaButton | ||||
|           class="is-success" | ||||
|           text="Türchen anzeigen" | ||||
|           @click.left=" | ||||
|             store.is_touch_device = true; | ||||
|             toast?.hide(); | ||||
|           " | ||||
|         /> | ||||
|       </div> | ||||
|       <div class="level-item"> | ||||
|         <BulmaButton | ||||
|           class="is-danger" | ||||
|           text="Ich möchte selbst suchen" | ||||
|           @click.left="toast?.hide()" | ||||
|         /> | ||||
|       </div> | ||||
|     </div> | ||||
|   </BulmaToast> | ||||
| 
 | ||||
|   <figure> | ||||
|     <div class="image is-unselectable"> | ||||
|       <img :src="store.calendar_background_image" /> | ||||
|       <ThouCanvas> | ||||
|         <CalendarDoor | ||||
|           v-for="(door, index) in doors" | ||||
|           :key="`door-${index}`" | ||||
|           :door="door" | ||||
|           :visible="store.is_touch_device" | ||||
|           :title="$advent22.name_door(door.day)" | ||||
|           @click="door_click(door.day)" | ||||
|           style="cursor: pointer" | ||||
|         /> | ||||
|       </ThouCanvas> | ||||
|     </div> | ||||
|   </figure> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Door } from "@/lib/door"; | ||||
| import { advent22Store } from "@/plugins/store"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import MultiModal from "./MultiModal.vue"; | ||||
| import BulmaButton from "./bulma/Button.vue"; | ||||
| import BulmaToast from "./bulma/Toast.vue"; | ||||
| import CalendarDoor from "./calendar/CalendarDoor.vue"; | ||||
| import ThouCanvas from "./calendar/ThouCanvas.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     MultiModal, | ||||
|     BulmaButton, | ||||
|     BulmaToast, | ||||
|     ThouCanvas, | ||||
|     CalendarDoor, | ||||
|   }, | ||||
|   props: { | ||||
|     doors: Array, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public readonly doors!: Door[]; | ||||
|   public readonly store = advent22Store(); | ||||
| 
 | ||||
|   private multi_modal?: MultiModal; | ||||
| 
 | ||||
|   public toast?: BulmaToast; | ||||
|   private toast_timeout?: number; | ||||
| 
 | ||||
|   public modal_handle(modal: MultiModal) { | ||||
|     this.multi_modal = modal; | ||||
|   } | ||||
| 
 | ||||
|   public toast_handle(toast: BulmaToast) { | ||||
|     this.toast = toast; | ||||
| 
 | ||||
|     if (this.store.is_touch_device) return; | ||||
| 
 | ||||
|     this.store.when_initialized(() => { | ||||
|       this.toast_timeout = setTimeout(() => { | ||||
|         if (this.store.user_doors.length === 0) return; | ||||
|         if (this.store.is_touch_device) return; | ||||
| 
 | ||||
|         this.toast!.show({ duration: 600000, type: "is-warning" }); | ||||
|       }, 10e3); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   public door_click(day: number) { | ||||
|     if (this.toast_timeout !== undefined) clearTimeout(this.toast_timeout); | ||||
|     this.toast?.hide(); | ||||
| 
 | ||||
|     if (this.multi_modal === undefined) return; | ||||
|     this.multi_modal.show_progress(); | ||||
| 
 | ||||
|     this.$advent22 | ||||
|       .api_get_blob(`user/image_${day}`) | ||||
|       .then((image_src) => { | ||||
|         this.multi_modal!.show_image(image_src, this.$advent22.name_door(day)); | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         this.store.alert_user_error(error); | ||||
|         this.multi_modal!.hide(); | ||||
|       }); | ||||
|   } | ||||
| 
 | ||||
|   public beforeUnmount(): void { | ||||
|     this.toast?.hide(); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | @ -1,55 +0,0 @@ | |||
| <template> | ||||
|   {{ string_repr }} | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Duration } from "luxon"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| @Options({ | ||||
|   props: { | ||||
|     until: Number, | ||||
|     tick_time: { | ||||
|       type: Number, | ||||
|       default: 200, | ||||
|     }, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   private until!: number; | ||||
|   private tick_time!: number; | ||||
| 
 | ||||
|   private interval_id: number | null = null; | ||||
|   public string_repr = ""; | ||||
| 
 | ||||
|   private tick(): void { | ||||
|     const distance_ms = this.until - Date.now(); | ||||
| 
 | ||||
|     if (distance_ms <= 0) { | ||||
|       this.string_repr = "Jetzt!"; | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const distance = Duration.fromMillis(distance_ms); | ||||
|     const d_days = distance.shiftTo("day").mapUnits(Math.floor); | ||||
|     const d_hms = distance.minus(d_days).shiftTo("hour", "minute", "second"); | ||||
| 
 | ||||
|     if (d_days.days > 0) { | ||||
|       this.string_repr = d_days.toHuman() + " "; | ||||
|     } else { | ||||
|       this.string_repr = ""; | ||||
|     } | ||||
|     this.string_repr += d_hms.toFormat("hh:mm:ss"); | ||||
|   } | ||||
| 
 | ||||
|   public mounted(): void { | ||||
|     this.tick(); | ||||
|     this.interval_id = window.setInterval(this.tick, this.tick_time); | ||||
|   } | ||||
| 
 | ||||
|   public beforeUnmount(): void { | ||||
|     if (this.interval_id === null) return; | ||||
|     window.clearInterval(this.interval_id); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | @ -1,94 +0,0 @@ | |||
| <template> | ||||
|   <div class="modal is-active"> | ||||
|     <div class="modal-background" /> | ||||
| 
 | ||||
|     <div class="modal-card"> | ||||
|       <header class="modal-card-head"> | ||||
|         <p class="modal-card-title">Login</p> | ||||
|         <button class="delete" @click.left="cancel" /> | ||||
|       </header> | ||||
| 
 | ||||
|       <section class="modal-card-body"> | ||||
|         <div class="field"> | ||||
|           <label class="label">Username</label> | ||||
|           <div class="control"> | ||||
|             <input | ||||
|               ref="username_input" | ||||
|               class="input" | ||||
|               type="text" | ||||
|               v-model="username" | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <div class="field"> | ||||
|           <label class="label">Passwort</label> | ||||
|           <div class="control"> | ||||
|             <input class="input" type="password" v-model="password" /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </section> | ||||
| 
 | ||||
|       <footer class="modal-card-foot is-flex is-justify-content-space-around"> | ||||
|         <BulmaButton | ||||
|           class="is-success" | ||||
|           @click.left="submit" | ||||
|           icon="fa-solid fa-unlock" | ||||
|           text="Login" | ||||
|         /> | ||||
|         <BulmaButton | ||||
|           class="is-danger" | ||||
|           @click.left="cancel" | ||||
|           icon="fa-solid fa-circle-xmark" | ||||
|           text="Abbrechen" | ||||
|         /> | ||||
|       </footer> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import BulmaButton from "./bulma/Button.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     BulmaButton, | ||||
|   }, | ||||
|   props: { | ||||
|     visible: Boolean, | ||||
|   }, | ||||
|   emits: ["cancel", "submit"], | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public username = ""; | ||||
|   public password = ""; | ||||
| 
 | ||||
|   private on_keydown(e: KeyboardEvent) { | ||||
|     if (e.key == "Enter") this.submit(); | ||||
|     else if (e.key == "Escape") this.cancel(); | ||||
|   } | ||||
| 
 | ||||
|   public mounted(): void { | ||||
|     window.addEventListener("keydown", this.on_keydown); | ||||
| 
 | ||||
|     this.$nextTick(() => { | ||||
|       if (!(this.$refs.username_input instanceof HTMLElement)) return; | ||||
|       this.$refs.username_input.focus(); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   public beforeUnmount(): void { | ||||
|     window.removeEventListener("keydown", this.on_keydown); | ||||
|   } | ||||
| 
 | ||||
|   public submit(): void { | ||||
|     this.$emit("submit", [this.username, this.password]); | ||||
|   } | ||||
| 
 | ||||
|   public cancel(): void { | ||||
|     this.$emit("cancel"); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | @ -1,83 +0,0 @@ | |||
| <template> | ||||
|   <div class="modal is-active" v-if="active" @click="dismiss()"> | ||||
|     <div class="modal-background" /> | ||||
| 
 | ||||
|     <div class="modal-content" style="max-height: 100vh; max-width: 95vw"> | ||||
|       <template v-if="progress"> | ||||
|         <progress class="progress is-primary" max="100" /> | ||||
|       </template> | ||||
|       <template v-else> | ||||
|         <figure> | ||||
|           <figcaption class="tag is-primary"> | ||||
|             {{ caption }} | ||||
|           </figcaption> | ||||
|           <div class="image is-square"> | ||||
|             <img :src="image_src" alt="Kalender-Bild" /> | ||||
|           </div> | ||||
|         </figure> | ||||
|       </template> | ||||
|     </div> | ||||
| 
 | ||||
|     <button | ||||
|       v-if="!progress" | ||||
|       class="modal-close is-large has-background-primary" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| @Options({ | ||||
|   emits: ["handle"], | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public active = false; | ||||
|   public progress = false; | ||||
|   public image_src = ""; | ||||
|   public caption = ""; | ||||
| 
 | ||||
|   private on_keydown(e: KeyboardEvent) { | ||||
|     if (e.key == "Escape") this.dismiss(); | ||||
|   } | ||||
| 
 | ||||
|   public created(): void { | ||||
|     this.$emit("handle", this); | ||||
|   } | ||||
| 
 | ||||
|   public mounted(): void { | ||||
|     window.addEventListener("keydown", this.on_keydown); | ||||
|   } | ||||
| 
 | ||||
|   public beforeUnmount(): void { | ||||
|     window.removeEventListener("keydown", this.on_keydown); | ||||
|   } | ||||
| 
 | ||||
|   public show() { | ||||
|     this.active = true; | ||||
|   } | ||||
| 
 | ||||
|   public hide() { | ||||
|     this.active = false; | ||||
|   } | ||||
| 
 | ||||
|   public dismiss() { | ||||
|     // Cannot dismiss the "loading" screen | ||||
|     if (this.active && this.progress) return; | ||||
| 
 | ||||
|     this.active = false; | ||||
|   } | ||||
| 
 | ||||
|   public show_image(src: string, caption: string = "") { | ||||
|     this.progress = false; | ||||
|     this.image_src = src; | ||||
|     this.caption = caption; | ||||
|     this.show(); | ||||
|   } | ||||
| 
 | ||||
|   public show_progress() { | ||||
|     this.progress = true; | ||||
|     this.show(); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | @ -1,28 +0,0 @@ | |||
| <template> | ||||
|   <span>Eingabemodus: </span> | ||||
|   <BulmaButton | ||||
|     v-bind="$attrs" | ||||
|     :icon=" | ||||
|       'fa-solid fa-' + | ||||
|       (store.is_touch_device ? 'hand-pointer' : 'arrow-pointer') | ||||
|     " | ||||
|     :text="store.is_touch_device ? 'Touch' : 'Desktop'" | ||||
|     @click.left="store.toggle_touch_device" | ||||
|   /> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { advent22Store } from "@/plugins/store"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import BulmaButton from "./bulma/Button.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     BulmaButton, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public readonly store = advent22Store(); | ||||
| } | ||||
| </script> | ||||
|  | @ -1,41 +0,0 @@ | |||
| <template> | ||||
|   <template v-if="store.is_initialized === true"> | ||||
|     <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> | ||||
|   <progress v-else class="progress is-primary" max="100" /> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { advent22Store } from "@/plugins/store"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import Calendar from "./Calendar.vue"; | ||||
| import CountDown from "./CountDown.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     Calendar, | ||||
|     CountDown, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public readonly store = advent22Store(); | ||||
| } | ||||
| </script> | ||||
|  | @ -1,22 +0,0 @@ | |||
| <template> | ||||
|   <ConfigView /> | ||||
|   <CalendarAssistant /> | ||||
|   <DoorMapEditor /> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import CalendarAssistant from "./CalendarAssistant.vue"; | ||||
| import ConfigView from "./ConfigView.vue"; | ||||
| import DoorMapEditor from "./DoorMapEditor.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     ConfigView, | ||||
|     CalendarAssistant, | ||||
|     DoorMapEditor, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue {} | ||||
| </script> | ||||
|  | @ -1,116 +0,0 @@ | |||
| <template> | ||||
|   <MultiModal @handle="modal_handle" /> | ||||
| 
 | ||||
|   <BulmaDrawer header="Kalender-Assistent" @open="on_open" refreshable> | ||||
|     <div class="card-content"> | ||||
|       <div class="content"> | ||||
|         <p>Hervorgehobenen Tagen wurde kein Buchstabe zugewiesen.</p> | ||||
| 
 | ||||
|         <h3>Zuordnung Buchstaben</h3> | ||||
|         <div class="tags are-medium"> | ||||
|           <template v-for="(data, day) in day_data" :key="`part-${day}`"> | ||||
|             <span v-if="data.part === ''" class="tag is-warning"> | ||||
|               {{ day }} | ||||
|             </span> | ||||
|             <span v-else class="tag is-info"> | ||||
|               {{ day }}: {{ data.part.split("").join(", ") }} | ||||
|             </span> | ||||
|           </template> | ||||
|         </div> | ||||
| 
 | ||||
|         <h3>Zuordnung Bilder</h3> | ||||
|         <div class="tags are-medium"> | ||||
|           <span | ||||
|             v-for="(data, day) in day_data" | ||||
|             :key="`image-${day}`" | ||||
|             :class="'tag is-' + (data.part === '' ? 'warning' : 'primary')" | ||||
|           > | ||||
|             {{ day }}: {{ data.image_name }} | ||||
|           </span> | ||||
|         </div> | ||||
| 
 | ||||
|         <h3>Alle Türchen</h3> | ||||
|         <div class="tags are-medium"> | ||||
|           <BulmaButton | ||||
|             v-for="(data, day) in day_data" | ||||
|             :key="`btn-${day}`" | ||||
|             :class="'tag is-' + (data.part === '' ? 'warning' : 'info')" | ||||
|             icon="fa-solid fa-door-open" | ||||
|             :text="day" | ||||
|             @click.left="door_click(day)" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </BulmaDrawer> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { NumStrDict, objForEach } from "@/lib/api"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import MultiModal from "../MultiModal.vue"; | ||||
| import BulmaButton from "../bulma/Button.vue"; | ||||
| import BulmaDrawer from "../bulma/Drawer.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     BulmaButton, | ||||
|     BulmaDrawer, | ||||
|     MultiModal, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public day_data: { | ||||
|     [day: number]: { | ||||
|       part: string; | ||||
|       image_name: string; | ||||
|     }; | ||||
|   } = {}; | ||||
| 
 | ||||
|   private multi_modal?: MultiModal; | ||||
| 
 | ||||
|   public modal_handle(modal: MultiModal) { | ||||
|     this.multi_modal = modal; | ||||
|   } | ||||
| 
 | ||||
|   public on_open(ready: () => void, fail: () => void): void { | ||||
|     Promise.all([ | ||||
|       this.$advent22.api_get<NumStrDict>("admin/day_parts"), | ||||
|       this.$advent22.api_get<NumStrDict>("admin/day_image_names"), | ||||
|     ]) | ||||
|       .then(([day_parts, day_image_names]) => { | ||||
|         const _ensure_day_in_data = (day: number) => { | ||||
|           if (!(day in this.day_data)) { | ||||
|             this.day_data[day] = { part: "", image_name: "" }; | ||||
|           } | ||||
|         }; | ||||
| 
 | ||||
|         objForEach(day_parts, (day, part) => { | ||||
|           _ensure_day_in_data(day); | ||||
|           this.day_data[day].part = part; | ||||
|         }); | ||||
| 
 | ||||
|         objForEach(day_image_names, (day, image_name) => { | ||||
|           _ensure_day_in_data(day); | ||||
|           this.day_data[day].image_name = image_name; | ||||
|         }); | ||||
| 
 | ||||
|         ready(); | ||||
|       }) | ||||
|       .catch(fail); | ||||
|   } | ||||
| 
 | ||||
|   public door_click(day: number) { | ||||
|     if (this.multi_modal === undefined) return; | ||||
|     this.multi_modal.show_progress(); | ||||
| 
 | ||||
|     this.$advent22 | ||||
|       .api_get_blob(`user/image_${day}`) | ||||
|       .then((image_src) => | ||||
|         this.multi_modal!.show_image(image_src, this.$advent22.name_door(day)), | ||||
|       ) | ||||
|       .catch(() => this.multi_modal!.hide()); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | @ -1,293 +0,0 @@ | |||
| <template> | ||||
|   <BulmaDrawer header="Konfiguration" @open="on_open" refreshable> | ||||
|     <div class="card-content"> | ||||
|       <div class="columns"> | ||||
|         <div class="column is-one-third"> | ||||
|           <div class="content"> | ||||
|             <h3>Lösung</h3> | ||||
|             <dl> | ||||
|               <dt>Wert</dt> | ||||
|               <dd> | ||||
|                 Eingabe: | ||||
|                 <span class="is-family-monospace"> | ||||
|                   "{{ admin_config_model.solution.value }}" | ||||
|                 </span> | ||||
|               </dd> | ||||
|               <dd> | ||||
|                 Ausgabe: | ||||
|                 <span class="is-family-monospace"> | ||||
|                   "{{ admin_config_model.solution.clean }}" | ||||
|                 </span> | ||||
|               </dd> | ||||
| 
 | ||||
|               <dt>Transformation</dt> | ||||
|               <dd> | ||||
|                 Whitespace: | ||||
|                 <span class="is-uppercase is-family-monospace"> | ||||
|                   {{ admin_config_model.solution.whitespace }} | ||||
|                 </span> | ||||
|               </dd> | ||||
|               <dd> | ||||
|                 Sonderzeichen: | ||||
|                 <span class="is-uppercase is-family-monospace"> | ||||
|                   {{ admin_config_model.solution.special_chars }} | ||||
|                 </span> | ||||
|               </dd> | ||||
|               <dd> | ||||
|                 Buchstaben: | ||||
|                 <span class="is-uppercase is-family-monospace"> | ||||
|                   {{ admin_config_model.solution.case }} | ||||
|                 </span> | ||||
|               </dd> | ||||
|             </dl> | ||||
| 
 | ||||
|             <h3>Rätsel</h3> | ||||
|             <dl> | ||||
|               <dt>Offene Türchen</dt> | ||||
|               <dd>{{ store.user_doors.length }}</dd> | ||||
| 
 | ||||
|               <dt>Zeit zum nächsten Türchen</dt> | ||||
|               <dd v-if="store.next_door_target === null"> | ||||
|                 Kein nächstes Türchen | ||||
|               </dd> | ||||
|               <dd v-else><CountDown :until="store.next_door_target" /></dd> | ||||
| 
 | ||||
|               <dt>Erstes Türchen</dt> | ||||
|               <dd>{{ fmt_puzzle_date("first") }}</dd> | ||||
| 
 | ||||
|               <dt>Nächstes Türchen</dt> | ||||
|               <dd>{{ fmt_puzzle_date("next") }}</dd> | ||||
| 
 | ||||
|               <dt>Letztes Türchen</dt> | ||||
|               <dd>{{ fmt_puzzle_date("last") }}</dd> | ||||
| 
 | ||||
|               <dt>Rätsel schließt nach</dt> | ||||
|               <dd>{{ fmt_puzzle_date("end") }}</dd> | ||||
| 
 | ||||
|               <dt>Zufalls-Seed</dt> | ||||
|               <dd class="is-family-monospace"> | ||||
|                 "{{ admin_config_model.puzzle.seed }}" | ||||
|               </dd> | ||||
| 
 | ||||
|               <dt>Extra-Tage</dt> | ||||
|               <dd> | ||||
|                 <template | ||||
|                   v-for="(day, index) in admin_config_model.puzzle.extra_days" | ||||
|                   :key="`extra_day-${index}`" | ||||
|                 > | ||||
|                   <span> | ||||
|                     <template v-if="index > 0">, </template> | ||||
|                     {{ day }} | ||||
|                   </span> | ||||
|                 </template> | ||||
|               </dd> | ||||
| 
 | ||||
|               <dt>Leere Türchen</dt> | ||||
|               <dd v-if="admin_config_model.puzzle.skip_empty">Überspringen</dd> | ||||
|               <dd v-else>Anzeigen</dd> | ||||
|             </dl> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="column is-one-third"> | ||||
|           <div class="content"> | ||||
|             <h3>Kalender</h3> | ||||
|             <dl> | ||||
|               <dt>Definition</dt> | ||||
|               <dd>{{ admin_config_model.calendar.config_file }}</dd> | ||||
| 
 | ||||
|               <dt>Hintergrundbild</dt> | ||||
|               <dd>{{ admin_config_model.calendar.background }}</dd> | ||||
| 
 | ||||
|               <dt>Favicon</dt> | ||||
|               <dd>{{ admin_config_model.calendar.favicon }}</dd> | ||||
| 
 | ||||
|               <dt>Türchen ({{ doors.length }} Stück)</dt> | ||||
|               <dd> | ||||
|                 <template v-for="(door, index) in doors" :key="`door-${index}`"> | ||||
|                   <span> | ||||
|                     <template v-if="index > 0">, </template> | ||||
|                     {{ door.day }} | ||||
|                   </span> | ||||
|                 </template> | ||||
|               </dd> | ||||
|             </dl> | ||||
| 
 | ||||
|             <h3>Bilder</h3> | ||||
|             <dl> | ||||
|               <dt>Größe</dt> | ||||
|               <dd>{{ admin_config_model.image.size }} px</dd> | ||||
| 
 | ||||
|               <dt>Rand</dt> | ||||
|               <dd>{{ admin_config_model.image.border }} px</dd> | ||||
| 
 | ||||
|               <dt>Schriftarten</dt> | ||||
|               <dd | ||||
|                 v-for="(font, index) in admin_config_model.fonts" | ||||
|                 :key="`font-${index}`" | ||||
|               > | ||||
|                 {{ font.file }} ({{ font.size }} pt) | ||||
|               </dd> | ||||
|             </dl> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="column is-one-third"> | ||||
|           <div class="content"> | ||||
|             <h3>WebDAV</h3> | ||||
|             <dl> | ||||
|               <dt>URL</dt> | ||||
|               <dd>{{ admin_config_model.webdav.url }}</dd> | ||||
| 
 | ||||
|               <dt>Zugangsdaten</dt> | ||||
|               <dd class="is-family-monospace"> | ||||
|                 <BulmaSecret @load="load_dav_credentials"> | ||||
|                   <span class="tag is-danger">user</span> | ||||
|                   {{ dav_credentials[0] }} | ||||
|                   <br /> | ||||
|                   <span class="tag is-danger">pass</span> | ||||
|                   {{ dav_credentials[1] }} | ||||
|                 </BulmaSecret> | ||||
|               </dd> | ||||
| 
 | ||||
|               <dt>Cache-Dauer</dt> | ||||
|               <dd>{{ admin_config_model.webdav.cache_ttl }} s</dd> | ||||
| 
 | ||||
|               <dt>Konfigurationsdatei</dt> | ||||
|               <dd>{{ admin_config_model.webdav.config_file }}</dd> | ||||
|             </dl> | ||||
|           </div> | ||||
| 
 | ||||
|           <div class="content"> | ||||
|             <h3>Sonstige</h3> | ||||
|             <dl> | ||||
|               <dt>Redis</dt> | ||||
|               <dd>Host: {{ admin_config_model.redis.host }}</dd> | ||||
|               <dd>Port: {{ admin_config_model.redis.port }}</dd> | ||||
|               <dd>Datenbank: {{ admin_config_model.redis.db }}</dd> | ||||
|               <dd>Protokoll: {{ admin_config_model.redis.protocol }}</dd> | ||||
| 
 | ||||
|               <dt>UI-Admin</dt> | ||||
|               <dd class="is-family-monospace"> | ||||
|                 <BulmaSecret @load="load_ui_credentials"> | ||||
|                   <span class="tag is-danger">user</span> | ||||
|                   {{ ui_credentials[0] }} | ||||
|                   <br /> | ||||
|                   <span class="tag is-danger">pass</span> | ||||
|                   {{ ui_credentials[1] }} | ||||
|                 </BulmaSecret> | ||||
|               </dd> | ||||
|             </dl> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </BulmaDrawer> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { AdminConfigModel, Credentials, DoorSaved } from "@/lib/api"; | ||||
| import { advent22Store } from "@/plugins/store"; | ||||
| import { DateTime } from "luxon"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import BulmaDrawer from "../bulma/Drawer.vue"; | ||||
| import BulmaSecret from "../bulma/Secret.vue"; | ||||
| import CountDown from "../CountDown.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     BulmaDrawer, | ||||
|     BulmaSecret, | ||||
|     CountDown, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public readonly store = advent22Store(); | ||||
| 
 | ||||
|   public admin_config_model: AdminConfigModel = { | ||||
|     solution: { | ||||
|       value: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", | ||||
|       whitespace: "KEEP", | ||||
|       special_chars: "KEEP", | ||||
|       case: "KEEP", | ||||
|       clean: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", | ||||
|     }, | ||||
|     puzzle: { | ||||
|       first: "2023-12-01", | ||||
|       next: "2023-12-01", | ||||
|       last: "2023-12-24", | ||||
|       end: "2024-04-01", | ||||
|       seed: "", | ||||
|       extra_days: [], | ||||
|       skip_empty: true, | ||||
|     }, | ||||
|     calendar: { | ||||
|       config_file: "lorem ipsum", | ||||
|       background: "dolor sit", | ||||
|       favicon: "sit amet", | ||||
|     }, | ||||
|     image: { | ||||
|       size: 500, | ||||
|       border: 0, | ||||
|     }, | ||||
|     fonts: [{ file: "consetetur", size: 0 }], | ||||
|     redis: { | ||||
|       host: "0.0.0.0", | ||||
|       port: 6379, | ||||
|       db: 0, | ||||
|       protocol: 3, | ||||
|     }, | ||||
|     webdav: { | ||||
|       url: "sadipscing elitr", | ||||
|       cache_ttl: 0, | ||||
|       config_file: "sed diam nonumy", | ||||
|     }, | ||||
|   }; | ||||
|   public doors: DoorSaved[] = []; | ||||
|   public dav_credentials: Credentials = ["", ""]; | ||||
|   public ui_credentials: Credentials = ["", ""]; | ||||
| 
 | ||||
|   public fmt_puzzle_date(name: keyof AdminConfigModel["puzzle"]): string { | ||||
|     const iso_date = this.admin_config_model.puzzle[name]; | ||||
|     if (!(typeof iso_date == "string")) return "-"; | ||||
| 
 | ||||
|     return DateTime.fromISO(iso_date).toLocaleString(DateTime.DATE_SHORT); | ||||
|   } | ||||
| 
 | ||||
|   public on_open(ready: () => void, fail: () => void): void { | ||||
|     Promise.all([ | ||||
|       this.store.update(), | ||||
|       this.$advent22.api_get<AdminConfigModel>("admin/config_model"), | ||||
|       this.$advent22.api_get<DoorSaved[]>("admin/doors"), | ||||
|     ]) | ||||
|       .then(([store_update, admin_config_model, doors]) => { | ||||
|         store_update; // discard value | ||||
| 
 | ||||
|         this.admin_config_model = admin_config_model; | ||||
|         this.doors = doors; | ||||
| 
 | ||||
|         ready(); | ||||
|       }) | ||||
|       .catch(fail); | ||||
|   } | ||||
| 
 | ||||
|   public load_dav_credentials(): void { | ||||
|     this.$advent22 | ||||
|       .api_get<Credentials>("admin/dav_credentials") | ||||
|       .then((creds) => (this.dav_credentials = creds)) | ||||
|       .catch(() => {}); | ||||
|   } | ||||
| 
 | ||||
|   public load_ui_credentials(): void { | ||||
|     this.$advent22 | ||||
|       .api_get<Credentials>("admin/ui_credentials") | ||||
|       .then((creds) => (this.ui_credentials = creds)) | ||||
|       .catch(() => {}); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style scoped> | ||||
| dd { | ||||
|   overflow-x: auto; | ||||
| } | ||||
| </style> | ||||
|  | @ -1,195 +0,0 @@ | |||
| <template> | ||||
|   <BulmaDrawer header="Türchen bearbeiten" @open="on_open"> | ||||
|     <nav class="level is-mobile mb-0" style="overflow-x: auto"> | ||||
|       <BulmaButton | ||||
|         :disabled="current_step === 0" | ||||
|         class="level-item is-link" | ||||
|         @click="current_step--" | ||||
|         icon="fa-solid fa-backward" | ||||
|       /> | ||||
| 
 | ||||
|       <BulmaBreadcrumbs | ||||
|         :steps="steps" | ||||
|         v-model="current_step" | ||||
|         class="level-item mb-0" | ||||
|       /> | ||||
| 
 | ||||
|       <BulmaButton | ||||
|         :disabled="current_step === 2" | ||||
|         class="level-item is-link" | ||||
|         @click="current_step++" | ||||
|         icon="fa-solid fa-forward" | ||||
|       /> | ||||
|     </nav> | ||||
| 
 | ||||
|     <div class="card-content pb-0"> | ||||
|       <div v-if="doors.length > 0" class="content"> | ||||
|         <p>Für diese Tage ist ein Türchen vorhanden:</p> | ||||
|         <div class="tags"> | ||||
|           <span | ||||
|             v-for="(door, index) in doors.toSorted((a, b) => a.day - b.day)" | ||||
|             :key="`door-${index}`" | ||||
|             class="tag is-primary" | ||||
|           > | ||||
|             {{ door.day }} | ||||
|           </span> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <DoorPlacer v-if="current_step === 0" :doors="doors" /> | ||||
|     <DoorChooser v-if="current_step === 1" :doors="doors" /> | ||||
|     <div v-if="current_step === 2" class="card-content"> | ||||
|       <Calendar :doors="doors" /> | ||||
|     </div> | ||||
| 
 | ||||
|     <footer class="card-footer is-flex is-justify-content-space-around"> | ||||
|       <BulmaButton | ||||
|         class="card-footer-item is-danger" | ||||
|         @click="on_download" | ||||
|         icon="fa-solid fa-cloud-arrow-down" | ||||
|         :busy="loading_doors" | ||||
|         text="Laden" | ||||
|       /> | ||||
|       <BulmaButton | ||||
|         class="card-footer-item is-warning" | ||||
|         @click="on_discard" | ||||
|         icon="fa-solid fa-trash" | ||||
|         text="Löschen" | ||||
|       /> | ||||
|       <BulmaButton | ||||
|         class="card-footer-item is-success" | ||||
|         @click="on_upload" | ||||
|         icon="fa-solid fa-cloud-arrow-up" | ||||
|         :busy="saving_doors" | ||||
|         text="Speichern" | ||||
|       /> | ||||
|     </footer> | ||||
|   </BulmaDrawer> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { DoorSaved } from "@/lib/api"; | ||||
| import { Door } from "@/lib/door"; | ||||
| import { advent22Store } from "@/plugins/store"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import { toast } from "bulma-toast"; | ||||
| import Calendar from "../Calendar.vue"; | ||||
| import BulmaBreadcrumbs, { Step } from "../bulma/Breadcrumbs.vue"; | ||||
| import BulmaButton from "../bulma/Button.vue"; | ||||
| import BulmaDrawer from "../bulma/Drawer.vue"; | ||||
| import DoorChooser from "../editor/DoorChooser.vue"; | ||||
| import DoorPlacer from "../editor/DoorPlacer.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     BulmaBreadcrumbs, | ||||
|     BulmaButton, | ||||
|     BulmaDrawer, | ||||
|     DoorPlacer, | ||||
|     DoorChooser, | ||||
|     Calendar, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public readonly steps: Step[] = [ | ||||
|     { label: "Platzieren", icon: "fa-solid fa-crosshairs" }, | ||||
|     { label: "Ordnen", icon: "fa-solid fa-list-ol" }, | ||||
|     { label: "Vorschau", icon: "fa-solid fa-magnifying-glass" }, | ||||
|   ]; | ||||
|   public current_step = 0; | ||||
|   public doors: Door[] = []; | ||||
|   private readonly store = advent22Store(); | ||||
| 
 | ||||
|   public loading_doors = false; | ||||
|   public saving_doors = false; | ||||
| 
 | ||||
|   private load_doors(): Promise<void> { | ||||
|     return new Promise<void>((resolve, reject) => { | ||||
|       this.$advent22 | ||||
|         .api_get<DoorSaved[]>("admin/doors") | ||||
|         .then((data) => { | ||||
|           this.doors.length = 0; | ||||
| 
 | ||||
|           for (const value of data) { | ||||
|             this.doors.push(Door.load(value)); | ||||
|           } | ||||
| 
 | ||||
|           resolve(); | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           this.store.alert_user_error(error); | ||||
|           reject(); | ||||
|         }); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   private save_doors(): Promise<void> { | ||||
|     return new Promise<void>((resolve, reject) => { | ||||
|       const data: DoorSaved[] = []; | ||||
| 
 | ||||
|       for (const door of this.doors) { | ||||
|         data.push(door.save()); | ||||
|       } | ||||
| 
 | ||||
|       this.$advent22 | ||||
|         .api_put("admin/doors", data) | ||||
|         .then(resolve) | ||||
|         .catch((error) => { | ||||
|           this.store.alert_user_error(error); | ||||
|           reject(); | ||||
|         }); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   public on_open(ready: () => void, fail: () => void): void { | ||||
|     this.load_doors().then(ready).catch(fail); | ||||
|   } | ||||
| 
 | ||||
|   public on_download() { | ||||
|     if (confirm("Aktuelle Änderungen verwerfen und Status vom Server laden?")) { | ||||
|       this.loading_doors = true; | ||||
| 
 | ||||
|       this.load_doors() | ||||
|         .then(() => | ||||
|           toast({ | ||||
|             message: "Erfolgreich!", | ||||
|             type: "is-success", | ||||
|             duration: 2e3, | ||||
|           }), | ||||
|         ) | ||||
|         .catch(() => {}) | ||||
|         .finally(() => (this.loading_doors = false)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public on_discard() { | ||||
|     if (confirm("Alle Türchen löschen? (nur lokal)")) { | ||||
|       // empty `doors` array | ||||
|       this.doors.length = 0; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public on_upload() { | ||||
|     if (confirm("Aktuelle Änderungen an den Server schicken?")) { | ||||
|       this.saving_doors = true; | ||||
| 
 | ||||
|       this.save_doors() | ||||
|         .then(() => { | ||||
|           this.load_doors() | ||||
|             .then(() => | ||||
|               toast({ | ||||
|                 message: "Erfolgreich!", | ||||
|                 type: "is-success", | ||||
|                 duration: 2e3, | ||||
|               }), | ||||
|             ) | ||||
|             .catch(() => {}) | ||||
|             .finally(() => (this.saving_doors = false)); | ||||
|         }) | ||||
|         .catch(() => (this.saving_doors = false)); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | @ -1,48 +0,0 @@ | |||
| <template> | ||||
|   <nav class="breadcrumb has-succeeds-separator"> | ||||
|     <ul> | ||||
|       <li | ||||
|         v-for="(step, index) in steps" | ||||
|         :key="`step-${index}`" | ||||
|         :class="modelValue === index ? 'is-active' : ''" | ||||
|         @click.left="change_step(index)" | ||||
|       > | ||||
|         <a> | ||||
|           <span class="icon is-small"> | ||||
|             <font-awesome-icon :icon="step.icon" /> | ||||
|           </span> | ||||
|           <span>{{ step.label }}</span> | ||||
|         </a> | ||||
|       </li> | ||||
|     </ul> | ||||
|   </nav> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| export interface Step { | ||||
|   label: string; | ||||
|   icon: string; | ||||
| } | ||||
| 
 | ||||
| @Options({ | ||||
|   props: { | ||||
|     steps: Array, | ||||
|     modelValue: Number, | ||||
|   }, | ||||
|   emits: ["update:modelValue"], | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public steps!: Step[]; | ||||
|   public modelValue!: number; | ||||
| 
 | ||||
|   public change_step(next_step: number) { | ||||
|     if (next_step === this.modelValue) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.$emit("update:modelValue", next_step); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | @ -1,45 +0,0 @@ | |||
| <template> | ||||
|   <button class="button"> | ||||
|     <slot v-if="text === undefined" name="default"> | ||||
|       <font-awesome-icon | ||||
|         v-if="icon !== undefined" | ||||
|         :icon="icon" | ||||
|         :beat-fade="busy" | ||||
|       /> | ||||
|     </slot> | ||||
|     <template v-else> | ||||
|       <span v-if="icon !== undefined" class="icon"> | ||||
|         <slot name="default"> | ||||
|           <font-awesome-icon :icon="icon" :beat-fade="busy" /> | ||||
|         </slot> | ||||
|       </span> | ||||
|       <span>{{ text }}</span> | ||||
|     </template> | ||||
|   </button> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| @Options({ | ||||
|   props: { | ||||
|     icon: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|     }, | ||||
|     text: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|     }, | ||||
|     busy: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public icon?: string; | ||||
|   public text?: string; | ||||
|   public busy!: boolean; | ||||
| } | ||||
| </script> | ||||
|  | @ -1,105 +0,0 @@ | |||
| <template> | ||||
|   <div class="card"> | ||||
|     <header class="card-header is-unselectable" style="cursor: pointer"> | ||||
|       <p class="card-header-title" @click="toggle">{{ header }}</p> | ||||
| 
 | ||||
|       <p v-if="refreshable" class="card-header-icon px-0"> | ||||
|         <BulmaButton class="tag icon is-primary" @click="refresh"> | ||||
|           <font-awesome-icon | ||||
|             icon="fa-solid fa-arrows-rotate" | ||||
|             :spin="is_open && loading" | ||||
|           /> | ||||
|         </BulmaButton> | ||||
|       </p> | ||||
| 
 | ||||
|       <button class="card-header-icon" @click="toggle"> | ||||
|         <span class="icon"> | ||||
|           <font-awesome-icon | ||||
|             :icon="'fa-solid fa-angle-' + (is_open ? 'down' : 'right')" | ||||
|           /> | ||||
|         </span> | ||||
|       </button> | ||||
|     </header> | ||||
| 
 | ||||
|     <template v-if="is_open"> | ||||
|       <div v-if="loading" class="card-content"> | ||||
|         <progress class="progress is-primary" max="100" /> | ||||
|       </div> | ||||
|       <div | ||||
|         v-else-if="failed" | ||||
|         class="card-content has-text-danger has-text-centered" | ||||
|       > | ||||
|         <span class="icon is-large"> | ||||
|           <font-awesome-icon icon="fa-solid fa-ban" size="3x" /> | ||||
|         </span> | ||||
|       </div> | ||||
|       <slot v-else name="default" /> | ||||
|     </template> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import BulmaButton from "./Button.vue"; | ||||
| 
 | ||||
| enum DrawerState { | ||||
|   Loading, | ||||
|   Ready, | ||||
|   Failed, | ||||
| } | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     BulmaButton, | ||||
|   }, | ||||
|   props: { | ||||
|     header: String, | ||||
|     refreshable: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|   }, | ||||
|   emits: ["open"], | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public header!: string; | ||||
|   public refreshable!: boolean; | ||||
| 
 | ||||
|   public is_open = false; | ||||
|   public state = DrawerState.Loading; | ||||
| 
 | ||||
|   public toggle() { | ||||
|     this.is_open = !this.is_open; | ||||
| 
 | ||||
|     if (this.is_open) { | ||||
|       this.state = DrawerState.Loading; | ||||
| 
 | ||||
|       this.$emit( | ||||
|         "open", | ||||
|         () => (this.state = DrawerState.Ready), | ||||
|         () => (this.state = DrawerState.Failed), | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public refresh() { | ||||
|     this.is_open = false; | ||||
|     this.toggle(); | ||||
|   } | ||||
| 
 | ||||
|   public get loading(): boolean { | ||||
|     return this.state === DrawerState.Loading; | ||||
|   } | ||||
| 
 | ||||
|   public get failed(): boolean { | ||||
|     return this.state === DrawerState.Failed; | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style scoped> | ||||
| div.card:not(:last-child) { | ||||
|   margin-bottom: 1.5rem; | ||||
| } | ||||
| </style> | ||||
|  | @ -1,68 +0,0 @@ | |||
| <template> | ||||
|   <slot v-if="show" name="default" /> | ||||
|   <span v-else>***</span> | ||||
|   <BulmaButton | ||||
|     :class="`tag icon is-${button_class} ml-2`" | ||||
|     :icon="`fa-solid fa-${button_icon}`" | ||||
|     :busy="busy" | ||||
|     @click="on_click" | ||||
|   /> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import BulmaButton from "./Button.vue"; | ||||
| 
 | ||||
| enum ClickState { | ||||
|   Green = 0, | ||||
|   Yellow = 1, | ||||
|   Red = 2, | ||||
| } | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     BulmaButton, | ||||
|   }, | ||||
|   emits: ["load"], | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public state = ClickState.Green; | ||||
| 
 | ||||
|   public on_click(): void { | ||||
|     this.state++; | ||||
|     this.state %= 3; | ||||
| 
 | ||||
|     if (this.state === ClickState.Red) { | ||||
|       this.$emit("load"); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public get show(): boolean { | ||||
|     return this.state === ClickState.Red; | ||||
|   } | ||||
| 
 | ||||
|   public get busy(): boolean { | ||||
|     return this.state === ClickState.Yellow; | ||||
|   } | ||||
| 
 | ||||
|   public get button_class(): string { | ||||
|     switch (this.state) { | ||||
|       case ClickState.Red: | ||||
|         return "danger"; | ||||
|       case ClickState.Yellow: | ||||
|         return "warning"; | ||||
|       default: | ||||
|         return "primary"; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public get button_icon(): string { | ||||
|     if (this.state === ClickState.Red) { | ||||
|       return "eye-slash"; | ||||
|     } else { | ||||
|       return "eye"; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | @ -1,43 +0,0 @@ | |||
| <template> | ||||
|   <div style="display: none"> | ||||
|     <div v-bind="$attrs" ref="message"> | ||||
|       <slot name="default" /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import * as bulmaToast from "bulma-toast"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| @Options({ | ||||
|   emits: ["handle"], | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public created(): void { | ||||
|     this.$emit("handle", this); | ||||
|   } | ||||
| 
 | ||||
|   public show(options: bulmaToast.Options = {}) { | ||||
|     if (!(this.$refs.message instanceof HTMLElement)) return; | ||||
| 
 | ||||
|     bulmaToast.toast({ | ||||
|       ...options, | ||||
|       single: true, | ||||
|       message: this.$refs.message, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   public hide() { | ||||
|     if (!(this.$refs.message instanceof HTMLElement)) return; | ||||
| 
 | ||||
|     const toast_div = this.$refs.message.parentElement; | ||||
|     if (!(toast_div instanceof HTMLDivElement)) return; | ||||
| 
 | ||||
|     const dbutton = toast_div.querySelector("button.delete"); | ||||
|     if (!(dbutton instanceof HTMLButtonElement)) return; | ||||
| 
 | ||||
|     dbutton.click(); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | @ -1,41 +0,0 @@ | |||
| <template> | ||||
|   <SVGRect | ||||
|     variant="primary" | ||||
|     :visible="store.is_touch_device || force_visible" | ||||
|     :rectangle="door.position" | ||||
|   > | ||||
|     <div | ||||
|       class="has-text-danger" | ||||
|       style="text-shadow: 0 0 10px white, 0 0 20px white" | ||||
|     > | ||||
|       {{ door.day }} | ||||
|     </div> | ||||
|   </SVGRect> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Door } from "@/lib/door"; | ||||
| import { advent22Store } from "@/plugins/store"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import SVGRect from "./SVGRect.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     SVGRect, | ||||
|   }, | ||||
|   props: { | ||||
|     door: Door, | ||||
|     force_visible: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public readonly store = advent22Store(); | ||||
| 
 | ||||
|   public door!: Door; | ||||
|   public force_visible!: boolean; | ||||
| } | ||||
| </script> | ||||
|  | @ -1,86 +0,0 @@ | |||
| <template> | ||||
|   <foreignObject | ||||
|     :x="Math.round(store.calendar_aspect_ratio * rectangle.left)" | ||||
|     :y="rectangle.top" | ||||
|     :width="Math.round(store.calendar_aspect_ratio * rectangle.width)" | ||||
|     :height="rectangle.height" | ||||
|     :style="`transform: scaleX(${1 / store.calendar_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 ${extra_classes}`" | ||||
|       style="height: inherit" | ||||
|       :title="title" | ||||
|     > | ||||
|       <slot name="default" /> | ||||
|     </div> | ||||
|   </foreignObject> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Rectangle } from "@/lib/rectangle"; | ||||
| import { advent22Store } from "@/plugins/store"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| type BulmaVariant = | ||||
|   | "primary" | ||||
|   | "link" | ||||
|   | "info" | ||||
|   | "success" | ||||
|   | "warning" | ||||
|   | "danger"; | ||||
| 
 | ||||
| @Options({ | ||||
|   props: { | ||||
|     variant: String, | ||||
|     visible: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|     rectangle: Rectangle, | ||||
|     title: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|     }, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public readonly store = advent22Store(); | ||||
| 
 | ||||
|   private variant!: BulmaVariant; | ||||
|   private visible!: boolean; | ||||
|   public rectangle!: Rectangle; | ||||
|   public title?: string; | ||||
| 
 | ||||
|   public get extra_classes(): string { | ||||
|     let result = this.variant; | ||||
| 
 | ||||
|     if (this.visible) result += " visible"; | ||||
| 
 | ||||
|     return result; | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| @import "@/bulma-scheme"; | ||||
| 
 | ||||
| foreignObject > div { | ||||
|   &:not(.visible, :hover):deep() > * { | ||||
|     display: none; | ||||
|   } | ||||
| 
 | ||||
|   &.visible, | ||||
|   &:hover { | ||||
|     border-width: 2px; | ||||
|     border-style: solid; | ||||
| 
 | ||||
|     @each $name, $color in $advent22-colors { | ||||
|       &.#{$name} { | ||||
|         background-color: rgba($color, 0.3); | ||||
|         border-color: rgba($color, 0.9); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|  | @ -1,87 +0,0 @@ | |||
| <template> | ||||
|   <svg | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     version="1.1" | ||||
|     viewBox="0 0 1000 1000" | ||||
|     preserveAspectRatio="none" | ||||
|     @contextmenu.prevent | ||||
|     @mousedown="transform_mouse_event" | ||||
|     @mousemove="transform_mouse_event" | ||||
|     @mouseup="transform_mouse_event" | ||||
|     @click="transform_mouse_event" | ||||
|     @dblclick="transform_mouse_event" | ||||
|   > | ||||
|     <slot name="default" /> | ||||
|   </svg> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Vector2D } from "@/lib/vector2d"; | ||||
| import { advent22Store } from "@/plugins/store"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| 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), | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function mouse_event_validator(event: object, point: object): boolean { | ||||
|   if (!(event instanceof MouseEvent)) { | ||||
|     console.warn(event, "is not a MouseEvent!"); | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   if (!(point instanceof Vector2D)) { | ||||
|     console.warn(point, "is not a Vector2D!"); | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   return true; | ||||
| } | ||||
| 
 | ||||
| @Options({ | ||||
|   emits: { | ||||
|     mousedown: mouse_event_validator, | ||||
|     mouseup: mouse_event_validator, | ||||
|     mousemove: mouse_event_validator, | ||||
|     click: mouse_event_validator, | ||||
|     dblclick: mouse_event_validator, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public readonly store = advent22Store(); | ||||
| 
 | ||||
|   public mounted(): void { | ||||
|     new ResizeObserver(([first, ...rest]) => { | ||||
|       if (rest.length > 0) | ||||
|         console.warn(`Unexpected ${rest.length} extra entries!`); | ||||
| 
 | ||||
|       this.store.set_calendar_aspect_ratio(first.contentRect); | ||||
|     }).observe(this.$el); | ||||
|   } | ||||
| 
 | ||||
|   public transform_mouse_event(event: MouseEvent) { | ||||
|     const point = get_event_thous(event); | ||||
|     this.$emit(event.type, event, point); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style scoped> | ||||
| svg { | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
| 
 | ||||
|   position: absolute; | ||||
|   left: 0; | ||||
|   top: 0; | ||||
| 
 | ||||
|   z-index: auto; | ||||
| } | ||||
| </style> | ||||
|  | @ -1,157 +0,0 @@ | |||
| <template> | ||||
|   <ThouCanvas | ||||
|     @mousedown.left="draw_start" | ||||
|     @mouseup.left="draw_finish" | ||||
|     @mousedown.right="drag_start" | ||||
|     @mouseup.right="drag_finish" | ||||
|     @mousemove="on_mousemove" | ||||
|     @click.middle="remove_rect" | ||||
|     @dblclick.left="remove_rect" | ||||
|   > | ||||
|     <CalendarDoor | ||||
|       v-for="(door, index) in doors" | ||||
|       :key="`door-${index}`" | ||||
|       :door="door" | ||||
|       force_visible | ||||
|     /> | ||||
|     <SVGRect | ||||
|       v-if="preview_visible" | ||||
|       variant="success" | ||||
|       :rectangle="preview_rect" | ||||
|       visible | ||||
|     /> | ||||
|   </ThouCanvas> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Door } from "@/lib/door"; | ||||
| import { Rectangle } from "@/lib/rectangle"; | ||||
| import { Vector2D } from "@/lib/vector2d"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import CalendarDoor from "../calendar/CalendarDoor.vue"; | ||||
| import SVGRect from "../calendar/SVGRect.vue"; | ||||
| import ThouCanvas from "../calendar/ThouCanvas.vue"; | ||||
| 
 | ||||
| enum CanvasState { | ||||
|   Idle, | ||||
|   Drawing, | ||||
|   Dragging, | ||||
| } | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     CalendarDoor, | ||||
|     SVGRect, | ||||
|     ThouCanvas, | ||||
|   }, | ||||
|   props: { | ||||
|     doors: Array, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   private readonly min_rect_area = 300; | ||||
|   private state = CanvasState.Idle; | ||||
|   public preview_rect = new Rectangle(); | ||||
|   private drag_door?: Door; | ||||
|   private drag_origin = new Vector2D(); | ||||
|   public doors!: Door[]; | ||||
| 
 | ||||
|   public get preview_visible(): boolean { | ||||
|     return this.state !== CanvasState.Idle; | ||||
|   } | ||||
| 
 | ||||
|   private pop_door(point: Vector2D): Door | undefined { | ||||
|     const idx = this.doors.findIndex((rect) => rect.position.contains(point)); | ||||
| 
 | ||||
|     if (idx === -1) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     return this.doors.splice(idx, 1)[0]; | ||||
|   } | ||||
| 
 | ||||
|   public draw_start(event: MouseEvent, point: Vector2D) { | ||||
|     if (this.preview_visible) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.state = CanvasState.Drawing; | ||||
|     this.preview_rect = new Rectangle(point, point); | ||||
|   } | ||||
| 
 | ||||
|   public draw_finish() { | ||||
|     if (this.state !== CanvasState.Drawing || this.preview_rect === undefined) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.state = CanvasState.Idle; | ||||
| 
 | ||||
|     if (this.preview_rect.area < this.min_rect_area) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.doors.push(new Door(this.preview_rect)); | ||||
|   } | ||||
| 
 | ||||
|   public drag_start(event: MouseEvent, point: Vector2D) { | ||||
|     if (this.preview_visible) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.drag_door = this.pop_door(point); | ||||
| 
 | ||||
|     if (this.drag_door === undefined) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.state = CanvasState.Dragging; | ||||
|     this.drag_origin = point; | ||||
| 
 | ||||
|     this.preview_rect = this.drag_door.position; | ||||
|   } | ||||
| 
 | ||||
|   public drag_finish() { | ||||
|     if ( | ||||
|       this.state !== CanvasState.Dragging || | ||||
|       this.preview_rect === undefined | ||||
|     ) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.state = CanvasState.Idle; | ||||
|     this.doors.push(new Door(this.preview_rect, this.drag_door!.day)); | ||||
|   } | ||||
| 
 | ||||
|   public on_mousemove(event: MouseEvent, point: Vector2D) { | ||||
|     if (this.preview_rect === undefined) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (this.state === CanvasState.Drawing) { | ||||
|       this.preview_rect = this.preview_rect.update(undefined, point); | ||||
|     } else if (this.state === CanvasState.Dragging && this.drag_door) { | ||||
|       const movement = point.minus(this.drag_origin); | ||||
|       this.preview_rect = this.drag_door.position.move(movement); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public remove_rect(event: MouseEvent, point: Vector2D) { | ||||
|     if (this.preview_visible) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.pop_door(point); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| svg { | ||||
|   cursor: crosshair; | ||||
| 
 | ||||
|   * { | ||||
|     pointer-events: none; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|  | @ -1,47 +0,0 @@ | |||
| <template> | ||||
|   <div class="card-content"> | ||||
|     <div class="content is-small"> | ||||
|       <h3>Steuerung</h3> | ||||
|       <ul> | ||||
|         <li>Linksklick: Türchen bearbeiten</li> | ||||
|         <li>Tastatur: Tag eingeben</li> | ||||
|         <li>[Enter]: Tag speichern</li> | ||||
|         <li>[Esc]: Eingabe Abbrechen</li> | ||||
|         <li>[Entf]: Tag entfernen</li> | ||||
|       </ul> | ||||
|     </div> | ||||
|     <figure class="image is-unselectable"> | ||||
|       <img :src="store.calendar_background_image" /> | ||||
|       <ThouCanvas> | ||||
|         <PreviewDoor | ||||
|           v-for="(door, index) in doors" | ||||
|           :key="`door-${index}`" | ||||
|           :door="door" | ||||
|         /> | ||||
|       </ThouCanvas> | ||||
|     </figure> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Door } from "@/lib/door"; | ||||
| import { advent22Store } from "@/plugins/store"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import ThouCanvas from "../calendar/ThouCanvas.vue"; | ||||
| import PreviewDoor from "./PreviewDoor.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     ThouCanvas, | ||||
|     PreviewDoor, | ||||
|   }, | ||||
|   props: { | ||||
|     doors: Array, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public doors!: Door[]; | ||||
|   public readonly store = advent22Store(); | ||||
| } | ||||
| </script> | ||||
|  | @ -1,37 +0,0 @@ | |||
| <template> | ||||
|   <div class="card-content"> | ||||
|     <div class="content is-small"> | ||||
|       <h3>Steuerung</h3> | ||||
|       <ul> | ||||
|         <li>Linksklick + Ziehen: Neues Türchen erstellen</li> | ||||
|         <li>Rechtsklick + Ziehen: Türchen verschieben</li> | ||||
|         <li>Doppel- oder Mittelklick: Türchen löschen</li> | ||||
|       </ul> | ||||
|     </div> | ||||
|     <figure class="image is-unselectable"> | ||||
|       <img :src="store.calendar_background_image" /> | ||||
|       <DoorCanvas :doors="doors" /> | ||||
|     </figure> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Door } from "@/lib/door"; | ||||
| import { advent22Store } from "@/plugins/store"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import DoorCanvas from "./DoorCanvas.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     DoorCanvas, | ||||
|   }, | ||||
|   props: { | ||||
|     doors: Array, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public doors!: Door[]; | ||||
|   public readonly store = advent22Store(); | ||||
| } | ||||
| </script> | ||||
|  | @ -1,89 +0,0 @@ | |||
| <template> | ||||
|   <SVGRect | ||||
|     style="cursor: text" | ||||
|     :rectangle="door.position" | ||||
|     :variant="editing ? 'success' : 'primary'" | ||||
|     @click.left="on_click" | ||||
|     visible | ||||
|   > | ||||
|     <input | ||||
|       v-if="editing" | ||||
|       v-model="day_str" | ||||
|       ref="day_input" | ||||
|       class="input is-large" | ||||
|       type="number" | ||||
|       :min="MIN_DAY" | ||||
|       placeholder="Tag" | ||||
|       @keydown="on_keydown" | ||||
|     /> | ||||
|     <div v-else class="has-text-danger"> | ||||
|       {{ door.day > 0 ? door.day : "*" }} | ||||
|     </div> | ||||
|   </SVGRect> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { Door } from "@/lib/door"; | ||||
| import { Options, Vue } from "vue-class-component"; | ||||
| 
 | ||||
| import SVGRect from "../calendar/SVGRect.vue"; | ||||
| 
 | ||||
| @Options({ | ||||
|   components: { | ||||
|     SVGRect, | ||||
|   }, | ||||
|   props: { | ||||
|     door: Door, | ||||
|   }, | ||||
| }) | ||||
| export default class extends Vue { | ||||
|   public door!: Door; | ||||
|   public readonly MIN_DAY = Door.MIN_DAY; | ||||
| 
 | ||||
|   public day_str = ""; | ||||
|   public editing = false; | ||||
| 
 | ||||
|   private toggle_editing() { | ||||
|     this.day_str = String(this.door.day); | ||||
|     this.editing = !this.editing; | ||||
|   } | ||||
| 
 | ||||
|   public on_click(event: MouseEvent) { | ||||
|     if (!(event.target instanceof HTMLDivElement)) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (!this.editing) { | ||||
|       const day_input_focus = () => { | ||||
|         if (this.$refs.day_input instanceof HTMLInputElement) { | ||||
|           this.$refs.day_input.select(); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         this.$nextTick(day_input_focus); | ||||
|       }; | ||||
|       day_input_focus(); | ||||
|     } else { | ||||
|       this.door.day = this.day_str; | ||||
|     } | ||||
| 
 | ||||
|     this.toggle_editing(); | ||||
|   } | ||||
| 
 | ||||
|   public on_keydown(event: KeyboardEvent) { | ||||
|     if (!this.editing) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (event.key === "Enter") { | ||||
|       this.door.day = this.day_str; | ||||
|       this.toggle_editing(); | ||||
|     } else if (event.key === "Delete") { | ||||
|       this.door.day = -1; | ||||
|       this.toggle_editing(); | ||||
|     } else if (event.key === "Escape") { | ||||
|       this.toggle_editing(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										10
									
								
								ui/src/d.ts/shims-advent22.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								ui/src/d.ts/shims-advent22.d.ts
									
									
									
									
										vendored
									
									
								
							|  | @ -1,10 +0,0 @@ | |||
| import { Advent22 } from "@/plugins/advent22"; | ||||
| 
 | ||||
| declare module "@vue/runtime-core" { | ||||
|   // bind to `this` keyword
 | ||||
|   interface ComponentCustomProperties { | ||||
|     $advent22: Advent22; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export {}; | ||||
							
								
								
									
										6
									
								
								ui/src/d.ts/shims-vue.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								ui/src/d.ts/shims-vue.d.ts
									
									
									
									
										vendored
									
									
								
							|  | @ -1,6 +0,0 @@ | |||
| /* eslint-disable */ | ||||
| declare module "*.vue" { | ||||
|   import type { DefineComponent } from "vue"; | ||||
|   const component: DefineComponent<{}, {}, any>; | ||||
|   export default component; | ||||
| } | ||||
|  | @ -1,71 +0,0 @@ | |||
| export interface AdminConfigModel { | ||||
|   solution: { | ||||
|     value: string; | ||||
|     whitespace: string; | ||||
|     special_chars: string; | ||||
|     case: string; | ||||
|     clean: string; | ||||
|   }; | ||||
|   puzzle: { | ||||
|     first: string; | ||||
|     next: string | null; | ||||
|     last: string; | ||||
|     end: string; | ||||
|     seed: string; | ||||
|     extra_days: number[]; | ||||
|     skip_empty: boolean; | ||||
|   }; | ||||
|   calendar: { | ||||
|     config_file: string; | ||||
|     background: string; | ||||
|     favicon: string; | ||||
|   }; | ||||
|   image: { | ||||
|     size: number; | ||||
|     border: number; | ||||
|   }; | ||||
|   fonts: { file: string; size: number }[]; | ||||
|   redis: { | ||||
|     host: string; | ||||
|     port: number; | ||||
|     db: number; | ||||
|     protocol: number; | ||||
|   }; | ||||
|   webdav: { | ||||
|     url: string; | ||||
|     cache_ttl: number; | ||||
|     config_file: string; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export interface SiteConfigModel { | ||||
|   title: string; | ||||
|   subtitle: string; | ||||
|   content: string; | ||||
|   footer: string; | ||||
| } | ||||
| 
 | ||||
| export interface NumStrDict { | ||||
|   [key: number]: string; | ||||
| } | ||||
| 
 | ||||
| export interface DoorSaved { | ||||
|   day: number; | ||||
|   x1: number; | ||||
|   y1: number; | ||||
|   x2: number; | ||||
|   y2: number; | ||||
| } | ||||
| 
 | ||||
| export type Credentials = [username: string, password: string]; | ||||
| 
 | ||||
| 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]); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -1,52 +0,0 @@ | |||
| import { DoorSaved } from "./api"; | ||||
| import { Rectangle } from "./rectangle"; | ||||
| import { Vector2D } from "./vector2d"; | ||||
| 
 | ||||
| export class Door { | ||||
|   public static readonly MIN_DAY = 1; | ||||
| 
 | ||||
|   private _day = Door.MIN_DAY; | ||||
|   public position: Rectangle; | ||||
| 
 | ||||
|   constructor(position: Rectangle); | ||||
|   constructor(position: Rectangle, day: number); | ||||
|   constructor(position: Rectangle, day = Door.MIN_DAY) { | ||||
|     this.day = day; | ||||
|     this.position = position; | ||||
|   } | ||||
| 
 | ||||
|   public get day(): number { | ||||
|     return this._day; | ||||
|   } | ||||
| 
 | ||||
|   public set day(day: unknown) { | ||||
|     // integer coercion
 | ||||
|     const result = Number(day); | ||||
| 
 | ||||
|     if (isNaN(result)) { | ||||
|       this._day = Door.MIN_DAY; | ||||
|     } else { | ||||
|       this._day = Math.max(Math.floor(result), Door.MIN_DAY); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public static load(serialized: DoorSaved): Door { | ||||
|     return new Door( | ||||
|       new Rectangle( | ||||
|         new Vector2D(serialized.x1, serialized.y1), | ||||
|         new Vector2D(serialized.x2, serialized.y2), | ||||
|       ), | ||||
|       serialized.day, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public save(): DoorSaved { | ||||
|     return { | ||||
|       day: this.day, | ||||
|       x1: this.position.origin.x, | ||||
|       y1: this.position.origin.y, | ||||
|       x2: this.position.corner.x, | ||||
|       y2: this.position.corner.y, | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|  | @ -1,79 +0,0 @@ | |||
| import { Vector2D } from "./vector2d"; | ||||
| 
 | ||||
| export class Rectangle { | ||||
|   private readonly corner_1: Vector2D; | ||||
|   private readonly corner_2: Vector2D; | ||||
| 
 | ||||
|   constructor(); | ||||
|   constructor(corner_1: Vector2D, corner_2: Vector2D); | ||||
|   constructor(corner_1 = new Vector2D(), corner_2 = new Vector2D()) { | ||||
|     this.corner_1 = corner_1; | ||||
|     this.corner_2 = corner_2; | ||||
|   } | ||||
| 
 | ||||
|   public get origin(): Vector2D { | ||||
|     return new Vector2D( | ||||
|       Math.min(this.corner_1.x, this.corner_2.x), | ||||
|       Math.min(this.corner_1.y, this.corner_2.y), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public get left(): number { | ||||
|     return this.origin.x; | ||||
|   } | ||||
| 
 | ||||
|   public get top(): number { | ||||
|     return this.origin.y; | ||||
|   } | ||||
| 
 | ||||
|   public get corner(): Vector2D { | ||||
|     return new Vector2D( | ||||
|       Math.max(this.corner_1.x, this.corner_2.x), | ||||
|       Math.max(this.corner_1.y, this.corner_2.y), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public get size(): Vector2D { | ||||
|     return this.corner.minus(this.origin); | ||||
|   } | ||||
| 
 | ||||
|   public get width(): number { | ||||
|     return this.size.x; | ||||
|   } | ||||
| 
 | ||||
|   public get height(): number { | ||||
|     return this.size.y; | ||||
|   } | ||||
| 
 | ||||
|   public get middle(): Vector2D { | ||||
|     return this.origin.plus(this.size.scale(0.5)); | ||||
|   } | ||||
| 
 | ||||
|   public get area(): number { | ||||
|     return this.width * this.height; | ||||
|   } | ||||
| 
 | ||||
|   public equals(other: Rectangle): boolean { | ||||
|     return this.origin.equals(other.origin) && this.corner.equals(other.corner); | ||||
|   } | ||||
| 
 | ||||
|   public contains(point: Vector2D): boolean { | ||||
|     return ( | ||||
|       point.x >= this.origin.x && | ||||
|       point.y >= this.origin.y && | ||||
|       point.x <= this.corner.x && | ||||
|       point.y <= this.corner.y | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   public update(corner_1?: Vector2D, corner_2?: Vector2D): Rectangle { | ||||
|     return new Rectangle(corner_1 || this.corner_1, corner_2 || this.corner_2); | ||||
|   } | ||||
| 
 | ||||
|   public move(vector: Vector2D): Rectangle { | ||||
|     return new Rectangle( | ||||
|       this.corner_1.plus(vector), | ||||
|       this.corner_2.plus(vector), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | @ -1,27 +0,0 @@ | |||
| export class Vector2D { | ||||
|   public readonly x: number; | ||||
|   public readonly y: number; | ||||
| 
 | ||||
|   constructor(); | ||||
|   constructor(x: number, y: number); | ||||
|   constructor(x = 0, y = 0) { | ||||
|     this.x = x; | ||||
|     this.y = y; | ||||
|   } | ||||
| 
 | ||||
|   public plus(other: Vector2D): Vector2D { | ||||
|     return new Vector2D(this.x + other.x, this.y + other.y); | ||||
|   } | ||||
| 
 | ||||
|   public minus(other: Vector2D): Vector2D { | ||||
|     return new Vector2D(this.x - other.x, this.y - other.y); | ||||
|   } | ||||
| 
 | ||||
|   public scale(other: number): Vector2D { | ||||
|     return new Vector2D(this.x * other, this.y * other); | ||||
|   } | ||||
| 
 | ||||
|   public equals(other: Vector2D): boolean { | ||||
|     return this.x === other.x && this.y === other.y; | ||||
|   } | ||||
| } | ||||
|  | @ -1,64 +0,0 @@ | |||
| @charset "utf-8"; | ||||
| 
 | ||||
| //=========== | ||||
| // variables | ||||
| //=========== | ||||
| 
 | ||||
| // custom color scheme | ||||
| @import "@/bulma-scheme"; | ||||
| 
 | ||||
| // Sass variables (bulma) | ||||
| @import "~bulma/sass/utilities/initial-variables.sass"; | ||||
| @import "~bulma/sass/utilities/derived-variables.sass"; | ||||
| 
 | ||||
| // Sass variables (bulma-prefers-dark) | ||||
| @import "~bulma-prefers-dark/sass/utilities/initial-variables.sass"; | ||||
| @import "~bulma-prefers-dark/sass/utilities/derived-variables.sass"; | ||||
| 
 | ||||
| //================= | ||||
| // variable tweaks | ||||
| //================= | ||||
| 
 | ||||
| $modal-card-body-background-color-dark: $body-background-dark; | ||||
| $card-background-color-dark: $background-dark; | ||||
| 
 | ||||
| //============== | ||||
| // main imports | ||||
| //============== | ||||
| 
 | ||||
| @import "~animate.css/animate"; | ||||
| @import "~bulma/bulma"; | ||||
| @import "~bulma-prefers-dark/bulma-prefers-dark"; | ||||
| 
 | ||||
| //============== | ||||
| // style tweaks | ||||
| //============== | ||||
| 
 | ||||
| .card-header { | ||||
|   background-color: $background; | ||||
| 
 | ||||
|   @include prefers-scheme(dark) { | ||||
|     background-color: $card-header-background-color; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .card-content { | ||||
|   @include prefers-scheme(dark) { | ||||
|     background-color: $body-background-dark; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .progress { | ||||
|   // &::-webkit-progress-bar { | ||||
|   //   background-color: transparent !important; | ||||
|   // } | ||||
|   // &::-webkit-progress-value { | ||||
|   //   background-color: transparent !important; | ||||
|   // } | ||||
|   &::-moz-progress-bar { | ||||
|     background-color: transparent !important; | ||||
|   } | ||||
|   // &::-ms-fill { | ||||
|   //   background-color: transparent !important; | ||||
|   // } | ||||
| } | ||||
|  | @ -1,29 +0,0 @@ | |||
| import { Advent22Plugin } from "@/plugins/advent22"; | ||||
| import { FontAwesomePlugin } from "@/plugins/fontawesome"; | ||||
| import { advent22Store } from "@/plugins/store"; | ||||
| import * as bulmaToast from "bulma-toast"; | ||||
| import { createPinia } from "pinia"; | ||||
| import { createApp } from "vue"; | ||||
| import App from "./App.vue"; | ||||
| 
 | ||||
| import "@/main.scss"; | ||||
| 
 | ||||
| const app = createApp(App); | ||||
| 
 | ||||
| app.use(Advent22Plugin); | ||||
| app.use(FontAwesomePlugin); | ||||
| 
 | ||||
| app.use(createPinia()); | ||||
| advent22Store().init(); | ||||
| 
 | ||||
| app.mount("#app"); | ||||
| 
 | ||||
| bulmaToast.setDefaults({ | ||||
|   duration: 10e3, | ||||
|   pauseOnHover: true, | ||||
|   dismissible: true, | ||||
|   closeOnClick: false, | ||||
|   type: "is-white", | ||||
|   position: "top-center", | ||||
|   animate: { in: "backInDown", out: "backOutUp" }, | ||||
| }); | ||||
|  | @ -1,110 +0,0 @@ | |||
| import axios, { AxiosInstance, ResponseType } from "axios"; | ||||
| import { App, Plugin } from "vue"; | ||||
| import { advent22Store } from "./store"; | ||||
| 
 | ||||
| export class Advent22 { | ||||
|   private axios: AxiosInstance; | ||||
| 
 | ||||
|   public constructor() { | ||||
|     this.axios = axios.create({ | ||||
|       timeout: 10e3, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   private get api_baseurl(): string { | ||||
|     // in production mode, return "//host/api"
 | ||||
|     if (process.env.NODE_ENV === "production") { | ||||
|       return `//${window.location.host}/api`; | ||||
|     } else if (process.env.NODE_ENV !== "development") { | ||||
|       // not in prouction or development mode
 | ||||
|       console.warn("Unexpected NODE_ENV value"); | ||||
|     } | ||||
| 
 | ||||
|     // in development mode, return "//hostname:8000/api"
 | ||||
|     return `//${window.location.hostname}:8000/api`; | ||||
|   } | ||||
| 
 | ||||
|   public name_door(day: number): string { | ||||
|     return `Türchen ${day}`; | ||||
|   } | ||||
| 
 | ||||
|   public api_url(): string; | ||||
|   public api_url(endpoint: string): string; | ||||
|   public api_url(endpoint?: string): string { | ||||
|     if (endpoint === undefined) { | ||||
|       return this.api_baseurl; | ||||
|     } | ||||
| 
 | ||||
|     while (endpoint.startsWith("/")) { | ||||
|       endpoint = endpoint.substring(1); | ||||
|     } | ||||
| 
 | ||||
|     return `${this.api_baseurl}/${endpoint}`; | ||||
|   } | ||||
| 
 | ||||
|   private _api_get<T>(endpoint: string): Promise<T>; | ||||
|   private _api_get<T>(endpoint: string, responseType: ResponseType): Promise<T>; | ||||
|   private _api_get<T>( | ||||
|     endpoint: string, | ||||
|     responseType: ResponseType = "json", | ||||
|   ): Promise<T> { | ||||
|     const req_config = { | ||||
|       auth: advent22Store().axios_creds, | ||||
|       responseType: responseType, | ||||
|     }; | ||||
| 
 | ||||
|     return new Promise<T>((resolve, reject) => { | ||||
|       this.axios | ||||
|         .get<T>(this.api_url(endpoint), req_config) | ||||
|         .then((response) => resolve(response.data)) | ||||
|         .catch((reason) => { | ||||
|           console.error(`Failed to query ${endpoint}: ${reason}`); | ||||
|           reject([reason, endpoint]); | ||||
|         }); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   public api_get<T>(endpoint: string): Promise<T> { | ||||
|     return this._api_get<T>(endpoint); | ||||
|   } | ||||
| 
 | ||||
|   public api_get_blob(endpoint: string): Promise<string> { | ||||
|     return new Promise<string>((resolve, reject) => { | ||||
|       this._api_get<Blob>(endpoint, "blob") | ||||
|         .then((data: Blob) => { | ||||
|           const reader = new FileReader(); | ||||
|           reader.readAsDataURL(data); | ||||
|           reader.onloadend = () => { | ||||
|             if (typeof reader.result === "string") { | ||||
|               resolve(reader.result); | ||||
|             } else { | ||||
|               reject(["failed data url", endpoint]); | ||||
|             } | ||||
|           }; | ||||
|         }) | ||||
|         .catch(reject); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   public api_put(endpoint: string, data: unknown): Promise<void> { | ||||
|     const req_config = { | ||||
|       auth: advent22Store().axios_creds, | ||||
|     }; | ||||
| 
 | ||||
|     return new Promise<void>((resolve, reject) => { | ||||
|       this.axios | ||||
|         .put(this.api_url(endpoint), data, req_config) | ||||
|         .then(() => resolve()) | ||||
|         .catch((reason) => { | ||||
|           console.error(`Failed to query ${endpoint}: ${reason}`); | ||||
|           reject([reason, endpoint]); | ||||
|         }); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export const Advent22Plugin: Plugin = { | ||||
|   install(app: App) { | ||||
|     app.config.globalProperties.$advent22 = new Advent22(); | ||||
|   }, | ||||
| }; | ||||
|  | @ -1,20 +0,0 @@ | |||
| import { App, Plugin } from "vue"; | ||||
| 
 | ||||
| /* import the fontawesome core */ | ||||
| import { library } from "@fortawesome/fontawesome-svg-core"; | ||||
| 
 | ||||
| /* import specific icons */ | ||||
| import { fab } from "@fortawesome/free-brands-svg-icons"; | ||||
| import { fas } from "@fortawesome/free-solid-svg-icons"; | ||||
| 
 | ||||
| /* add icons to the library */ | ||||
| library.add(fas, fab); | ||||
| 
 | ||||
| /* import font awesome icon component */ | ||||
| import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; | ||||
| 
 | ||||
| export const FontAwesomePlugin: Plugin = { | ||||
|   install(app: App) { | ||||
|     app.component("font-awesome-icon", FontAwesomeIcon); | ||||
|   }, | ||||
| }; | ||||
|  | @ -1,233 +0,0 @@ | |||
| import { Credentials, DoorSaved, SiteConfigModel } from "@/lib/api"; | ||||
| import { Door } from "@/lib/door"; | ||||
| import { Advent22 } from "@/plugins/advent22"; | ||||
| import { RemovableRef, useLocalStorage } from "@vueuse/core"; | ||||
| import { AxiosBasicCredentials, AxiosError } from "axios"; | ||||
| import { toast } from "bulma-toast"; | ||||
| import { acceptHMRUpdate, defineStore } from "pinia"; | ||||
| 
 | ||||
| declare global { | ||||
|   interface Navigator { | ||||
|     readonly msMaxTouchPoints: number; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| type State = { | ||||
|   advent22: Advent22; | ||||
|   api_creds: RemovableRef<Credentials>; | ||||
|   is_initialized: boolean; | ||||
|   on_initialized: (() => void)[]; | ||||
|   is_touch_device: boolean; | ||||
|   is_admin: boolean; | ||||
|   site_config: SiteConfigModel; | ||||
|   calendar_background_image: string | undefined; | ||||
|   calendar_aspect_ratio: number; | ||||
|   user_doors: Door[]; | ||||
|   next_door_target: number | null; | ||||
| }; | ||||
| 
 | ||||
| export const advent22Store = defineStore({ | ||||
|   id: "advent22", | ||||
| 
 | ||||
|   state: (): State => ({ | ||||
|     advent22: new Advent22(), | ||||
|     api_creds: useLocalStorage("advent22/auth", ["", ""]), | ||||
|     is_initialized: false, | ||||
|     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: "", | ||||
|     }, | ||||
|     calendar_background_image: undefined, | ||||
|     calendar_aspect_ratio: 1, | ||||
|     user_doors: [], | ||||
|     next_door_target: null, | ||||
|   }), | ||||
| 
 | ||||
|   getters: { | ||||
|     axios_creds: (state): AxiosBasicCredentials => { | ||||
|       const [username, password] = state.api_creds; | ||||
|       return { username: username, password: password }; | ||||
|     }, | ||||
|   }, | ||||
| 
 | ||||
|   actions: { | ||||
|     init(): void { | ||||
|       this.update() | ||||
|         .then(() => { | ||||
|           this.is_initialized = true; | ||||
|           for (const callback of this.on_initialized) callback(); | ||||
|         }) | ||||
|         .catch(this.alert_user_error); | ||||
|     }, | ||||
| 
 | ||||
|     format_user_error([reason, endpoint]: [unknown, string]): string { | ||||
|       let msg = | ||||
|         "Unbekannter Fehler, bitte wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!"; | ||||
|       let code = "U"; | ||||
|       const result = () => `${msg} (Fehlercode: ${code}/${endpoint})`; | ||||
| 
 | ||||
|       if (!(reason instanceof AxiosError)) return result(); | ||||
| 
 | ||||
|       switch (reason.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 (reason.response === undefined) return result(); | ||||
| 
 | ||||
|           switch (reason.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${reason.response.status}`; | ||||
|               break; | ||||
|           } | ||||
|           break; | ||||
|       } | ||||
| 
 | ||||
|       return result(); | ||||
|     }, | ||||
| 
 | ||||
|     alert_user_error(param: [unknown, string]): void { | ||||
|       toast({ | ||||
|         message: this.format_user_error(param), | ||||
|         type: "is-danger", | ||||
|       }); | ||||
|     }, | ||||
| 
 | ||||
|     update(): Promise<void> { | ||||
|       return new Promise((resolve, reject) => { | ||||
|         this.advent22 | ||||
|           .api_get_blob("user/favicon") | ||||
|           .then((favicon_src) => { | ||||
|             const link: HTMLLinkElement = | ||||
|               document.querySelector("link[rel*='icon']") || | ||||
|               document.createElement("link"); | ||||
|             link.rel = "shortcut icon"; | ||||
|             link.type = "image/x-icon"; | ||||
|             link.href = favicon_src; | ||||
| 
 | ||||
|             if (link.parentElement === null) | ||||
|               document.getElementsByTagName("head")[0].appendChild(link); | ||||
|           }) | ||||
|           .catch(() => {}); | ||||
| 
 | ||||
|         Promise.all([ | ||||
|           this.update_is_admin(), | ||||
|           this.advent22.api_get<SiteConfigModel>("user/site_config"), | ||||
|           this.advent22.api_get_blob("user/background_image"), | ||||
|           this.advent22.api_get<DoorSaved[]>("user/doors"), | ||||
|           this.advent22.api_get<number | null>("user/next_door"), | ||||
|         ]) | ||||
|           .then( | ||||
|             ([ | ||||
|               is_admin, | ||||
|               site_config, | ||||
|               background_image, | ||||
|               user_doors, | ||||
|               next_door, | ||||
|             ]) => { | ||||
|               is_admin; // discard value
 | ||||
| 
 | ||||
|               document.title = site_config.title; | ||||
| 
 | ||||
|               if (site_config.subtitle !== "") | ||||
|                 document.title += " – " + site_config.subtitle; | ||||
| 
 | ||||
|               this.site_config = site_config; | ||||
| 
 | ||||
|               this.calendar_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; | ||||
| 
 | ||||
|               resolve(); | ||||
|             }, | ||||
|           ) | ||||
|           .catch(reject); | ||||
|       }); | ||||
|     }, | ||||
| 
 | ||||
|     when_initialized(callback: () => void): void { | ||||
|       if (this.is_initialized) { | ||||
|         callback(); | ||||
|       } else { | ||||
|         this.on_initialized.push(callback); | ||||
|       } | ||||
|     }, | ||||
| 
 | ||||
|     update_is_admin(): Promise<boolean> { | ||||
|       return new Promise((resolve, reject) => { | ||||
|         this.advent22 | ||||
|           .api_get<boolean>("admin/is_admin") | ||||
|           .then((is_admin) => { | ||||
|             this.is_admin = is_admin; | ||||
|             resolve(is_admin); | ||||
|           }) | ||||
|           .catch(reject); | ||||
|       }); | ||||
|     }, | ||||
| 
 | ||||
|     login(creds: Credentials): Promise<boolean> { | ||||
|       this.api_creds = creds; | ||||
|       return this.update_is_admin(); | ||||
|     }, | ||||
| 
 | ||||
|     logout(): Promise<boolean> { | ||||
|       return this.login(["", ""]); | ||||
|     }, | ||||
| 
 | ||||
|     toggle_touch_device(): void { | ||||
|       this.is_touch_device = !this.is_touch_device; | ||||
|     }, | ||||
| 
 | ||||
|     set_calendar_aspect_ratio(rect: DOMRectReadOnly): void { | ||||
|       const result = rect.width / rect.height; | ||||
| 
 | ||||
|       // filter suspicious results
 | ||||
|       if (result !== 0 && isFinite(result) && !isNaN(result)) | ||||
|         this.calendar_aspect_ratio = result; | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| if (import.meta.webpackHot) { | ||||
|   import.meta.webpackHot.accept( | ||||
|     acceptHMRUpdate(advent22Store, import.meta.webpackHot), | ||||
|   ); | ||||
| } | ||||
|  | @ -1,90 +0,0 @@ | |||
| import { expect } from "chai"; | ||||
| 
 | ||||
| import { Rectangle } from "@/lib/rectangle"; | ||||
| import { Vector2D } from "@/lib/vector2d"; | ||||
| 
 | ||||
| describe("Rectangle Tests", () => { | ||||
|   const v1 = new Vector2D(1, 2); | ||||
|   const v2 = new Vector2D(4, 6); | ||||
| 
 | ||||
|   const r1 = new Rectangle(v1, v2); | ||||
|   const r2 = new Rectangle(v2, v1); | ||||
| 
 | ||||
|   function check_rectangle( | ||||
|     r: Rectangle, | ||||
|     left: number, | ||||
|     top: number, | ||||
|     width: number, | ||||
|     height: number, | ||||
|   ) { | ||||
|     expect(r.left).to.equal(left); | ||||
|     expect(r.top).to.equal(top); | ||||
| 
 | ||||
|     expect(r.width).to.equal(width); | ||||
|     expect(r.height).to.equal(height); | ||||
|     expect(r.area).to.equal(width * height); | ||||
| 
 | ||||
|     expect(r.middle.x).to.equal(left + 0.5 * width); | ||||
|     expect(r.middle.y).to.equal(top + 0.5 * height); | ||||
|   } | ||||
| 
 | ||||
|   it("should create a default rectangle", () => { | ||||
|     check_rectangle(new Rectangle(), 0, 0, 0, 0); | ||||
|   }); | ||||
| 
 | ||||
|   it("should create a rectangle", () => { | ||||
|     check_rectangle(r1, 1, 2, 3, 4); | ||||
|   }); | ||||
| 
 | ||||
|   it("should create the same rectangle backwards", () => { | ||||
|     check_rectangle(r2, 1, 2, 3, 4); | ||||
|   }); | ||||
| 
 | ||||
|   it("should compare rectangles", () => { | ||||
|     expect(r1.equals(r2)).to.be.true; | ||||
|     expect(r1.equals(new Rectangle())).to.be.false; | ||||
|   }); | ||||
| 
 | ||||
|   it("should create the same rectangle transposed", () => { | ||||
|     const v1t = new Vector2D(v1.x, v2.y); | ||||
|     const v2t = new Vector2D(v2.x, v1.y); | ||||
| 
 | ||||
|     expect(r1.equals(new Rectangle(v1t, v2t))).to.be.true; | ||||
|   }); | ||||
| 
 | ||||
|   it("should contain itself", () => { | ||||
|     expect(r1.contains(v1)).to.be.true; | ||||
|     expect(r1.contains(v2)).to.be.true; | ||||
| 
 | ||||
|     expect(r1.contains(r1.origin)).to.be.true; | ||||
|     expect(r1.contains(r1.corner)).to.be.true; | ||||
|     expect(r1.contains(r1.middle)).to.be.true; | ||||
|   }); | ||||
| 
 | ||||
|   it("should not contain certain points", () => { | ||||
|     expect(r1.contains(new Vector2D(0, 0))).to.be.false; | ||||
|     expect(r1.contains(new Vector2D(100, 100))).to.be.false; | ||||
|   }); | ||||
| 
 | ||||
|   it("should update a rectangle", () => { | ||||
|     const v = new Vector2D(1, 1); | ||||
| 
 | ||||
|     check_rectangle(r1.update(v1.plus(v), undefined), 2, 3, 2, 3); | ||||
|     check_rectangle(r1.update(v1.minus(v), undefined), 0, 1, 4, 5); | ||||
| 
 | ||||
|     check_rectangle(r1.update(undefined, v2.plus(v)), 1, 2, 4, 5); | ||||
|     check_rectangle(r1.update(undefined, v2.minus(v)), 1, 2, 2, 3); | ||||
| 
 | ||||
|     check_rectangle(r1.update(v1.plus(v), v2.plus(v)), 2, 3, 3, 4); | ||||
|     check_rectangle(r1.update(v1.minus(v), v2.minus(v)), 0, 1, 3, 4); | ||||
| 
 | ||||
|     check_rectangle(r1.update(v1.minus(v), v2.plus(v)), 0, 1, 5, 6); | ||||
|     check_rectangle(r1.update(v1.plus(v), v2.minus(v)), 2, 3, 1, 2); | ||||
|   }); | ||||
| 
 | ||||
|   it("should move a rectangle", () => { | ||||
|     const v = new Vector2D(1, 1); | ||||
| 
 | ||||
|     check_rectangle(r1.move(v), 2, 3, 3, 4); | ||||
|   }); | ||||
| }); | ||||
|  | @ -1,41 +0,0 @@ | |||
| import { expect } from "chai"; | ||||
| 
 | ||||
| import { Vector2D } from "@/lib/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; | ||||
|   }); | ||||
| }); | ||||
|  | @ -1,43 +0,0 @@ | |||
| { | ||||
|   "compilerOptions": { | ||||
|     "target": "esnext", | ||||
|     "module": "esnext", | ||||
|     "strict": true, | ||||
|     "jsx": "preserve", | ||||
|     "moduleResolution": "node", | ||||
|     "experimentalDecorators": true, | ||||
|     "skipLibCheck": true, | ||||
|     "esModuleInterop": true, | ||||
|     "allowSyntheticDefaultImports": true, | ||||
|     "forceConsistentCasingInFileNames": true, | ||||
|     "useDefineForClassFields": true, | ||||
|     "sourceMap": true, | ||||
|     "baseUrl": ".", | ||||
|     "types": [ | ||||
|       "webpack-env", | ||||
|       "mocha", | ||||
|       "chai" | ||||
|     ], | ||||
|     "paths": { | ||||
|       "@/*": [ | ||||
|         "src/*" | ||||
|       ] | ||||
|     }, | ||||
|     "lib": [ | ||||
|       "esnext", | ||||
|       "dom", | ||||
|       "dom.iterable", | ||||
|       "scripthost" | ||||
|     ] | ||||
|   }, | ||||
|   "include": [ | ||||
|     "src/**/*.ts", | ||||
|     "src/**/*.tsx", | ||||
|     "src/**/*.vue", | ||||
|     "tests/**/*.ts", | ||||
|     "tests/**/*.tsx" | ||||
|   ], | ||||
|   "exclude": [ | ||||
|     "node_modules" | ||||
|   ] | ||||
| } | ||||
|  | @ -1,25 +0,0 @@ | |||
| const { defineConfig } = require("@vue/cli-service"); | ||||
| const webpack = require("webpack"); | ||||
| 
 | ||||
| module.exports = defineConfig({ | ||||
|   transpileDependencies: true, | ||||
|   devServer: { | ||||
|     host: "localhost", | ||||
|   }, | ||||
|   pages: { | ||||
|     index: { | ||||
|       entry: "src/main.ts", | ||||
|       title: "Kalender-Gewinnspiel", | ||||
|     }, | ||||
|   }, | ||||
|   // https://stackoverflow.com/a/77765007
 | ||||
|   configureWebpack: { | ||||
|     plugins: [ | ||||
|       new webpack.DefinePlugin({ | ||||
|         // Vue CLI is in maintenance mode, and probably won't merge my PR to fix this in their tooling
 | ||||
|         // https://github.com/vuejs/vue-cli/pull/7443
 | ||||
|         __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false", | ||||
|       }), | ||||
|     ], | ||||
|   }, | ||||
| }); | ||||
							
								
								
									
										7548
									
								
								ui/yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										7548
									
								
								ui/yarn.lock
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Loading…
	
		Reference in a new issue