Compare commits
No commits in common. "feature/refactoring" and "master" have entirely different histories.
feature/re
...
master
85 changed files with 0 additions and 13985 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.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
api.conf
|
|
47
Dockerfile
47
Dockerfile
|
@ -1,47 +0,0 @@
|
||||||
############
|
|
||||||
# build ui #
|
|
||||||
############
|
|
||||||
|
|
||||||
ARG NODE_VERSION=18.16
|
|
||||||
ARG PYTHON_VERSION=3.11-slim
|
|
||||||
FROM node:${NODE_VERSION} AS build-ui
|
|
||||||
|
|
||||||
# env setup
|
|
||||||
WORKDIR /usr/local/src/advent22_ui
|
|
||||||
|
|
||||||
# install advent22_ui dependencies
|
|
||||||
COPY ui/package*.json ui/yarn*.lock ./
|
|
||||||
RUN yarn install --production false
|
|
||||||
|
|
||||||
# copy and build advent22_ui
|
|
||||||
COPY ui ./
|
|
||||||
RUN yarn build --dest /tmp/advent22_ui/html
|
|
||||||
|
|
||||||
###########
|
|
||||||
# web app #
|
|
||||||
###########
|
|
||||||
|
|
||||||
ARG PYTHON_VERSION
|
|
||||||
FROM tiangolo/uvicorn-gunicorn:python${PYTHON_VERSION} AS production
|
|
||||||
|
|
||||||
# env setup
|
|
||||||
WORKDIR /usr/local/src/advent22_api
|
|
||||||
ENV \
|
|
||||||
PRODUCTION_MODE="true" \
|
|
||||||
PORT="8000" \
|
|
||||||
MODULE_NAME="advent22_api.app"
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# install advent22_api
|
|
||||||
COPY api ./
|
|
||||||
RUN set -ex; \
|
|
||||||
# remove example app
|
|
||||||
rm -rf /app; \
|
|
||||||
\
|
|
||||||
python -m pip --no-cache-dir install ./
|
|
||||||
|
|
||||||
# add prepared advent22_ui
|
|
||||||
COPY --from=build-ui /tmp/advent22_ui /usr/local/share/advent22_ui
|
|
||||||
|
|
||||||
# run as unprivileged user
|
|
||||||
USER nobody
|
|
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,214 +0,0 @@
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import date
|
|
||||||
from io import BytesIO
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from fastapi import Depends
|
|
||||||
from PIL import Image, ImageFont
|
|
||||||
|
|
||||||
from .advent_image import _XY, AdventImage
|
|
||||||
from .calendar_config import CalendarConfig, get_calendar_config
|
|
||||||
from .config import Config, get_config
|
|
||||||
from .dav.webdav import WebDAV
|
|
||||||
from .helpers import (
|
|
||||||
RE_TTF,
|
|
||||||
EventDates,
|
|
||||||
Random,
|
|
||||||
list_fonts,
|
|
||||||
list_images_auto,
|
|
||||||
list_images_manual,
|
|
||||||
load_image,
|
|
||||||
set_len,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_all_sorted_days(
|
|
||||||
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
|
||||||
) -> list[int]:
|
|
||||||
"""
|
|
||||||
Alle Tage, für die es ein Türchen gibt
|
|
||||||
"""
|
|
||||||
|
|
||||||
return sorted(set(door.day for door in cal_cfg.doors))
|
|
||||||
|
|
||||||
|
|
||||||
async def get_all_parts(
|
|
||||||
cfg: Config = Depends(get_config),
|
|
||||||
days: list[int] = Depends(get_all_sorted_days),
|
|
||||||
) -> dict[int, str]:
|
|
||||||
"""
|
|
||||||
Lösung auf vorhandene Tage aufteilen
|
|
||||||
"""
|
|
||||||
|
|
||||||
# noch keine Buchstaben verteilt
|
|
||||||
result = {day: "" for day in days}
|
|
||||||
# extra-Tage ausfiltern
|
|
||||||
days = [day for day in days if day not in cfg.puzzle.extra_days]
|
|
||||||
|
|
||||||
solution_length = len(cfg.solution.clean)
|
|
||||||
num_days = len(days)
|
|
||||||
|
|
||||||
rnd = await Random.get()
|
|
||||||
solution_days = [
|
|
||||||
# wie oft passen die Tage "ganz" in die Länge der Lösung?
|
|
||||||
# zB 26 Buchstaben // 10 Tage == 2 mal => 2 Zeichen pro Tag
|
|
||||||
*rnd.shuffled(days * (solution_length // num_days)),
|
|
||||||
# wie viele Buchstaben bleiben übrig?
|
|
||||||
# zB 26 % 10 == 6 Buchstaben => an 6 Tagen ein Zeichen mehr
|
|
||||||
*rnd.sample(days, solution_length % num_days),
|
|
||||||
]
|
|
||||||
|
|
||||||
for day, letter in zip(solution_days, cfg.solution.clean):
|
|
||||||
result[day] += letter
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def get_all_event_dates(
|
|
||||||
cfg: Config = Depends(get_config),
|
|
||||||
days: list[int] = Depends(get_all_sorted_days),
|
|
||||||
parts: dict[int, str] = Depends(get_all_parts),
|
|
||||||
) -> EventDates:
|
|
||||||
"""
|
|
||||||
Aktueller Kalender-Zeitraum
|
|
||||||
"""
|
|
||||||
|
|
||||||
if cfg.puzzle.skip_empty:
|
|
||||||
days = [day for day in days if parts[day] != "" or day in cfg.puzzle.extra_days]
|
|
||||||
|
|
||||||
return EventDates(
|
|
||||||
today=date.today(),
|
|
||||||
begin_month=cfg.puzzle.begin_month,
|
|
||||||
begin_day=cfg.puzzle.begin_day,
|
|
||||||
events=days,
|
|
||||||
close_after=cfg.puzzle.close_after,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_all_auto_image_names(
|
|
||||||
days: list[int] = Depends(get_all_sorted_days),
|
|
||||||
images: list[str] = Depends(list_images_auto),
|
|
||||||
) -> dict[int, str]:
|
|
||||||
"""
|
|
||||||
Bilder: Reihenfolge zufällig bestimmen
|
|
||||||
"""
|
|
||||||
|
|
||||||
rnd = await Random.get()
|
|
||||||
ls = set_len(images, len(days))
|
|
||||||
|
|
||||||
return dict(zip(days, rnd.shuffled(ls)))
|
|
||||||
|
|
||||||
|
|
||||||
async def get_all_image_names(
|
|
||||||
auto_image_names: dict[int, str] = Depends(get_all_auto_image_names),
|
|
||||||
manual_image_names: list[str] = Depends(list_images_manual),
|
|
||||||
) -> dict[int, str]:
|
|
||||||
"""
|
|
||||||
Bilder "auto" und "manual" zu Tagen zuordnen
|
|
||||||
"""
|
|
||||||
|
|
||||||
num_re = re.compile(r"/(\d+)\.", flags=re.IGNORECASE)
|
|
||||||
|
|
||||||
for name in manual_image_names:
|
|
||||||
assert (num_match := num_re.search(name)) is not None
|
|
||||||
auto_image_names[int(num_match.group(1))] = name
|
|
||||||
|
|
||||||
return auto_image_names
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True, frozen=True)
|
|
||||||
class TTFont:
|
|
||||||
# Dateiname
|
|
||||||
file_name: str
|
|
||||||
|
|
||||||
# Schriftgröße für den Font
|
|
||||||
size: int = 50
|
|
||||||
|
|
||||||
@property
|
|
||||||
async def font(self) -> "ImageFont._Font":
|
|
||||||
return ImageFont.truetype(
|
|
||||||
font=BytesIO(await WebDAV.read_bytes(self.file_name)),
|
|
||||||
size=100,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_all_ttfonts(
|
|
||||||
font_names: list[str] = Depends(list_fonts),
|
|
||||||
) -> list[TTFont]:
|
|
||||||
result = []
|
|
||||||
|
|
||||||
for name in font_names:
|
|
||||||
assert (size_match := RE_TTF.search(name)) is not None
|
|
||||||
|
|
||||||
result.append(
|
|
||||||
TTFont(
|
|
||||||
file_name=name,
|
|
||||||
size=int(size_match.group(1)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def gen_day_auto_image(
|
|
||||||
day: int,
|
|
||||||
cfg: Config,
|
|
||||||
auto_image_names: dict[int, str],
|
|
||||||
day_parts: dict[int, str],
|
|
||||||
ttfonts: list[TTFont],
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Automatisch generiertes Bild erstellen
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Datei existiert garantiert!
|
|
||||||
img = await load_image(auto_image_names[day])
|
|
||||||
image = await AdventImage.from_img(img, cfg)
|
|
||||||
|
|
||||||
rnd = await Random.get(day)
|
|
||||||
xy_range = range(cfg.image.border, (cfg.image.size - cfg.image.border))
|
|
||||||
|
|
||||||
# Buchstaben verstecken
|
|
||||||
for letter in day_parts[day]:
|
|
||||||
await image.hide_text(
|
|
||||||
xy=cast(_XY, tuple(rnd.choices(xy_range, k=2))),
|
|
||||||
text=letter,
|
|
||||||
font=await rnd.choice(ttfonts).font,
|
|
||||||
)
|
|
||||||
|
|
||||||
return image.img
|
|
||||||
|
|
||||||
|
|
||||||
async def get_day_image(
|
|
||||||
day: int,
|
|
||||||
days: list[int] = Depends(get_all_sorted_days),
|
|
||||||
cfg: Config = Depends(get_config),
|
|
||||||
auto_image_names: dict[int, str] = Depends(get_all_auto_image_names),
|
|
||||||
day_parts: dict[int, str] = Depends(get_all_parts),
|
|
||||||
ttfonts: list[TTFont] = Depends(get_all_ttfonts),
|
|
||||||
) -> Image.Image | None:
|
|
||||||
"""
|
|
||||||
Bild für einen Tag abrufen
|
|
||||||
"""
|
|
||||||
|
|
||||||
if day not in days:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Versuche, aus "manual"-Ordner zu laden
|
|
||||||
img = await load_image(f"images_manual/{day}.jpg")
|
|
||||||
|
|
||||||
# Als AdventImage verarbeiten
|
|
||||||
image = await AdventImage.from_img(img, cfg)
|
|
||||||
return image.img
|
|
||||||
|
|
||||||
except RuntimeError:
|
|
||||||
# Erstelle automatisch generiertes Bild
|
|
||||||
return await gen_day_auto_image(
|
|
||||||
day=day,
|
|
||||||
cfg=cfg,
|
|
||||||
auto_image_names=auto_image_names,
|
|
||||||
day_parts=day_parts,
|
|
||||||
ttfonts=ttfonts,
|
|
||||||
)
|
|
|
@ -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,35 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
|
|
||||||
env: {
|
|
||||||
node: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
extends: [
|
|
||||||
"plugin:vue/vue3-essential",
|
|
||||||
"eslint:recommended",
|
|
||||||
"@vue/typescript/recommended",
|
|
||||||
],
|
|
||||||
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
},
|
|
||||||
|
|
||||||
rules: {
|
|
||||||
"no-empty": "off",
|
|
||||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
files: [
|
|
||||||
"**/__tests__/*.{j,t}s?(x)",
|
|
||||||
"**/tests/unit/**/*.spec.{j,t}s?(x)",
|
|
||||||
],
|
|
||||||
env: {
|
|
||||||
mocha: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
23
ui/.gitignore
vendored
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,45 +0,0 @@
|
||||||
{
|
|
||||||
"name": "advent22_ui",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8080",
|
|
||||||
"build": "vue-cli-service build",
|
|
||||||
"test:unit": "vue-cli-service test:unit",
|
|
||||||
"test:unit-watch": "vue-cli-service test:unit --watch",
|
|
||||||
"lint": "vue-cli-service lint"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
|
||||||
"@types/chai": "^4.3.17",
|
|
||||||
"@types/luxon": "^3.4.2",
|
|
||||||
"@types/mocha": "^10.0.7",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
|
||||||
"@typescript-eslint/parser": "^7.3.1",
|
|
||||||
"@vue/cli-plugin-babel": "~5.0.0",
|
|
||||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
|
||||||
"@vue/cli-plugin-typescript": "~5.0.0",
|
|
||||||
"@vue/cli-plugin-unit-mocha": "~5.0.0",
|
|
||||||
"@vue/cli-service": "~5.0.0",
|
|
||||||
"@vue/eslint-config-typescript": "^13.0.0",
|
|
||||||
"@vue/test-utils": "^2.4.6",
|
|
||||||
"animate.css": "^4.1.1",
|
|
||||||
"axios": "^1.7.3",
|
|
||||||
"bulma": "^1.0.2",
|
|
||||||
"bulma-toast": "2.4.3",
|
|
||||||
"chai": "^5.1.1",
|
|
||||||
"core-js": "^3.38.0",
|
|
||||||
"eslint": "^8.57.0",
|
|
||||||
"eslint-plugin-vue": "^9.27.0",
|
|
||||||
"luxon": "^3.5.0",
|
|
||||||
"pinia": "^2.2.1",
|
|
||||||
"sass": "^1.77.8",
|
|
||||||
"sass-loader": "^16.0.0",
|
|
||||||
"typescript": "~5.5.4",
|
|
||||||
"vue": "^3.4.37",
|
|
||||||
"vue-class-component": "^8.0.0-0"
|
|
||||||
},
|
|
||||||
"dependencies": {}
|
|
||||||
}
|
|
Binary file not shown.
Before Width: | Height: | Size: 38 KiB |
|
@ -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,78 +0,0 @@
|
||||||
<template>
|
|
||||||
<section class="hero is-small is-primary">
|
|
||||||
<div class="hero-body">
|
|
||||||
<h1 class="title is-uppercase">{{ store.site_config.title }}</h1>
|
|
||||||
<h2 class="subtitle">{{ store.site_config.subtitle }}</h2>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section px-3">
|
|
||||||
<progress
|
|
||||||
v-if="store.background_image === 'loading'"
|
|
||||||
class="progress is-primary"
|
|
||||||
max="100"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else-if="store.background_image === 'error'"
|
|
||||||
class="notification is-danger"
|
|
||||||
>
|
|
||||||
Hintergrundbild konnte nicht geladen werden
|
|
||||||
</div>
|
|
||||||
<div v-else class="container">
|
|
||||||
<AdminView v-if="store.is_admin" />
|
|
||||||
<UserView v-else />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="is-flex-grow-1" />
|
|
||||||
|
|
||||||
<footer class="footer">
|
|
||||||
<div class="level">
|
|
||||||
<div class="level-item">
|
|
||||||
<p v-html="store.site_config.footer" />
|
|
||||||
</div>
|
|
||||||
<div class="level-right">
|
|
||||||
<div class="level-item">
|
|
||||||
<TouchButton class="tag is-warning" />
|
|
||||||
</div>
|
|
||||||
<div class="level-item">
|
|
||||||
<AdminButton class="tag is-link is-outlined" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
import { advent22Store } from "./lib/store";
|
|
||||||
|
|
||||||
import AdminView from "./components/admin/AdminView.vue";
|
|
||||||
import AdminButton from "./components/AdminButton.vue";
|
|
||||||
import TouchButton from "./components/TouchButton.vue";
|
|
||||||
import UserView from "./components/UserView.vue";
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
AdminView,
|
|
||||||
AdminButton,
|
|
||||||
TouchButton,
|
|
||||||
UserView,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
public readonly store = advent22Store();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
html {
|
|
||||||
overflow-y: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
</style>
|
|
BIN
ui/src/assets/logo.png
(Stored with Git LFS)
BIN
ui/src/assets/logo.png
(Stored with Git LFS)
Binary file not shown.
|
@ -1,14 +0,0 @@
|
||||||
@charset "utf-8";
|
|
||||||
|
|
||||||
//=====================
|
|
||||||
// custom color scheme
|
|
||||||
//=====================
|
|
||||||
|
|
||||||
$colors: (
|
|
||||||
"primary": #945DE1,
|
|
||||||
"link": #64B4BD,
|
|
||||||
"info": #8C4E80,
|
|
||||||
"success": #7E8E2B,
|
|
||||||
"warning": #F6CA6B,
|
|
||||||
"danger": #C5443B,
|
|
||||||
);
|
|
|
@ -1,57 +0,0 @@
|
||||||
<template>
|
|
||||||
<LoginModal v-if="modal_visible" @submit="on_submit" @cancel="on_cancel" />
|
|
||||||
|
|
||||||
<BulmaButton
|
|
||||||
v-bind="$attrs"
|
|
||||||
:icon="'fa-solid fa-toggle-' + (store.is_admin ? 'on' : 'off')"
|
|
||||||
:busy="is_busy"
|
|
||||||
text="Admin"
|
|
||||||
@click.left="on_click"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Credentials } from "@/lib/model";
|
|
||||||
import { advent22Store } from "@/lib/store";
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
|
|
||||||
import { APIError } from "@/lib/api_error";
|
|
||||||
import BulmaButton from "./bulma/Button.vue";
|
|
||||||
import LoginModal from "./LoginModal.vue";
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
BulmaButton,
|
|
||||||
LoginModal,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
public modal_visible = false;
|
|
||||||
public is_busy = false;
|
|
||||||
public readonly store = advent22Store();
|
|
||||||
|
|
||||||
public on_click() {
|
|
||||||
if (this.store.is_admin) {
|
|
||||||
this.store.logout();
|
|
||||||
} else {
|
|
||||||
// show login modal
|
|
||||||
this.is_busy = true;
|
|
||||||
this.modal_visible = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public on_submit(creds: Credentials) {
|
|
||||||
this.modal_visible = false;
|
|
||||||
|
|
||||||
this.store
|
|
||||||
.login(creds)
|
|
||||||
.catch((error) => APIError.alert(error))
|
|
||||||
.finally(() => (this.is_busy = false));
|
|
||||||
}
|
|
||||||
|
|
||||||
public on_cancel() {
|
|
||||||
this.modal_visible = false;
|
|
||||||
this.is_busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,131 +0,0 @@
|
||||||
<template>
|
|
||||||
<MultiModal @handle="modal_handle" />
|
|
||||||
|
|
||||||
<BulmaToast @handle="toast_handle" class="content">
|
|
||||||
<p>
|
|
||||||
Du hast noch keine Türchen geöffnet, vielleicht gibt es ein Anzeigeproblem
|
|
||||||
in Deinem Webbrowser?
|
|
||||||
</p>
|
|
||||||
<div class="level">
|
|
||||||
<div class="level-item">
|
|
||||||
<BulmaButton
|
|
||||||
class="is-success"
|
|
||||||
text="Türchen anzeigen"
|
|
||||||
@click.left="
|
|
||||||
store.is_touch_device = true;
|
|
||||||
toast?.hide();
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="level-item">
|
|
||||||
<BulmaButton
|
|
||||||
class="is-danger"
|
|
||||||
text="Ich möchte selbst suchen"
|
|
||||||
@click.left="toast?.hide()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</BulmaToast>
|
|
||||||
|
|
||||||
<figure>
|
|
||||||
<div class="image is-unselectable">
|
|
||||||
<img :src="_ensure_loaded(store.background_image).data_url" />
|
|
||||||
<ThouCanvas>
|
|
||||||
<CalendarDoor
|
|
||||||
v-for="(door, index) in doors"
|
|
||||||
:key="`door-${index}`"
|
|
||||||
:door="door"
|
|
||||||
:visible="store.is_touch_device"
|
|
||||||
:title="_name_door(door.day)"
|
|
||||||
@click="door_click(door.day)"
|
|
||||||
style="cursor: pointer"
|
|
||||||
/>
|
|
||||||
</ThouCanvas>
|
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { API } from "@/lib/api";
|
|
||||||
import { APIError } from "@/lib/api_error";
|
|
||||||
import { ensure_loaded, Loading, name_door } from "@/lib/helpers";
|
|
||||||
import { ImageData } from "@/lib/model";
|
|
||||||
import { Door } from "@/lib/rects/door";
|
|
||||||
import { advent22Store } from "@/lib/store";
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
|
|
||||||
import MultiModal from "./MultiModal.vue";
|
|
||||||
import BulmaButton from "./bulma/Button.vue";
|
|
||||||
import BulmaToast from "./bulma/Toast.vue";
|
|
||||||
import CalendarDoor from "./calendar/CalendarDoor.vue";
|
|
||||||
import ThouCanvas from "./calendar/ThouCanvas.vue";
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
MultiModal,
|
|
||||||
BulmaButton,
|
|
||||||
BulmaToast,
|
|
||||||
ThouCanvas,
|
|
||||||
CalendarDoor,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
doors: Array,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
public readonly doors!: Door[];
|
|
||||||
public readonly store = advent22Store();
|
|
||||||
|
|
||||||
private multi_modal?: MultiModal;
|
|
||||||
|
|
||||||
public toast?: typeof BulmaToast;
|
|
||||||
private toast_timeout?: number;
|
|
||||||
|
|
||||||
public modal_handle(modal: MultiModal) {
|
|
||||||
this.multi_modal = modal;
|
|
||||||
}
|
|
||||||
|
|
||||||
public toast_handle(toast: typeof BulmaToast) {
|
|
||||||
this.toast = toast;
|
|
||||||
|
|
||||||
if (this.store.is_touch_device) return;
|
|
||||||
|
|
||||||
this.store.when_initialized(() => {
|
|
||||||
this.toast_timeout = setTimeout(() => {
|
|
||||||
if (this.store.user_doors.length === 0) return;
|
|
||||||
if (this.store.is_touch_device) return;
|
|
||||||
|
|
||||||
this.toast!.show({ duration: 600000, type: "is-warning" });
|
|
||||||
}, 10e3);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async door_click(day: number) {
|
|
||||||
if (this.toast_timeout !== undefined) clearTimeout(this.toast_timeout);
|
|
||||||
this.toast?.hide();
|
|
||||||
|
|
||||||
if (this.multi_modal === undefined) return;
|
|
||||||
this.multi_modal.show_progress();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const day_image = await API.request<ImageData>(`user/image_${day}`);
|
|
||||||
this.multi_modal!.show_image(day_image.data_url, name_door(day));
|
|
||||||
} catch (error) {
|
|
||||||
APIError.alert(error);
|
|
||||||
this.multi_modal!.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public beforeUnmount(): void {
|
|
||||||
this.toast?.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
public _ensure_loaded<T>(o: Loading<T>): T {
|
|
||||||
return ensure_loaded(o);
|
|
||||||
}
|
|
||||||
|
|
||||||
public _name_door(day: number): string {
|
|
||||||
return name_door(day);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -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 "@/lib/store";
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
|
|
||||||
import BulmaButton from "./bulma/Button.vue";
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
BulmaButton,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
public readonly store = advent22Store();
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,38 +0,0 @@
|
||||||
<template>
|
|
||||||
<Calendar :doors="store.user_doors" />
|
|
||||||
<hr />
|
|
||||||
<div class="content" v-html="store.site_config.content" />
|
|
||||||
<div class="content has-text-primary">
|
|
||||||
<template v-if="store.next_door_target === null">
|
|
||||||
Alle {{ store.user_doors.length }} Türchen offen!
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<template v-if="store.user_doors.length === 0">
|
|
||||||
Zeit bis zum ersten Türchen:
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ store.user_doors.length }} Türchen offen. Zeit bis zum nächsten
|
|
||||||
Türchen:
|
|
||||||
</template>
|
|
||||||
<CountDown :until="store.next_door_target" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { advent22Store } from "@/lib/store";
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
|
|
||||||
import Calendar from "./Calendar.vue";
|
|
||||||
import CountDown from "./CountDown.vue";
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
Calendar,
|
|
||||||
CountDown,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
public readonly store = advent22Store();
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -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,118 +0,0 @@
|
||||||
<template>
|
|
||||||
<MultiModal @handle="modal_handle" />
|
|
||||||
|
|
||||||
<BulmaDrawer header="Kalender-Assistent" @open="on_open" refreshable>
|
|
||||||
<div class="card-content">
|
|
||||||
<div class="content">
|
|
||||||
<p>Hervorgehobenen Tagen wurde kein Buchstabe zugewiesen.</p>
|
|
||||||
|
|
||||||
<h3>Zuordnung Buchstaben</h3>
|
|
||||||
<div class="tags are-medium">
|
|
||||||
<template v-for="(data, day) in day_data" :key="`part-${day}`">
|
|
||||||
<span v-if="data.part === ''" class="tag is-warning">
|
|
||||||
{{ day }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="tag is-info">
|
|
||||||
{{ day }}: {{ data.part.split("").join(", ") }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Zuordnung Bilder</h3>
|
|
||||||
<div class="tags are-medium">
|
|
||||||
<span
|
|
||||||
v-for="(data, day) in day_data"
|
|
||||||
:key="`image-${day}`"
|
|
||||||
:class="'tag is-' + (data.part === '' ? 'warning' : 'primary')"
|
|
||||||
>
|
|
||||||
{{ day }}: {{ data.image_name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Alle Türchen</h3>
|
|
||||||
<div class="tags are-medium">
|
|
||||||
<BulmaButton
|
|
||||||
v-for="(data, day) in day_data"
|
|
||||||
:key="`btn-${day}`"
|
|
||||||
:class="'tag is-' + (data.part === '' ? 'warning' : 'info')"
|
|
||||||
icon="fa-solid fa-door-open"
|
|
||||||
:text="day"
|
|
||||||
@click.left="door_click(day)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</BulmaDrawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { API } from "@/lib/api";
|
|
||||||
import { name_door, objForEach } from "@/lib/helpers";
|
|
||||||
import { ImageData, NumStrDict } from "@/lib/model";
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
|
|
||||||
import MultiModal from "../MultiModal.vue";
|
|
||||||
import BulmaButton from "../bulma/Button.vue";
|
|
||||||
import BulmaDrawer from "../bulma/Drawer.vue";
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
BulmaButton,
|
|
||||||
BulmaDrawer,
|
|
||||||
MultiModal,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
public day_data: {
|
|
||||||
[day: number]: {
|
|
||||||
part: string;
|
|
||||||
image_name: string;
|
|
||||||
};
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
private multi_modal?: MultiModal;
|
|
||||||
|
|
||||||
public modal_handle(modal: MultiModal) {
|
|
||||||
this.multi_modal = modal;
|
|
||||||
}
|
|
||||||
|
|
||||||
public on_open(ready: () => void, fail: () => void): void {
|
|
||||||
Promise.all([
|
|
||||||
API.request<NumStrDict>("admin/day_parts"),
|
|
||||||
API.request<NumStrDict>("admin/day_image_names"),
|
|
||||||
])
|
|
||||||
.then(([day_parts, day_image_names]) => {
|
|
||||||
const _ensure_day_in_data = (day: number) => {
|
|
||||||
if (!(day in this.day_data)) {
|
|
||||||
this.day_data[day] = { part: "", image_name: "" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
objForEach(day_parts, (day, part) => {
|
|
||||||
_ensure_day_in_data(day);
|
|
||||||
this.day_data[day].part = part;
|
|
||||||
});
|
|
||||||
|
|
||||||
objForEach(day_image_names, (day, image_name) => {
|
|
||||||
_ensure_day_in_data(day);
|
|
||||||
this.day_data[day].image_name = image_name;
|
|
||||||
});
|
|
||||||
|
|
||||||
ready();
|
|
||||||
})
|
|
||||||
.catch(fail);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async door_click(day: number) {
|
|
||||||
if (this.multi_modal === undefined) return;
|
|
||||||
this.multi_modal.show_progress();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const day_image = await API.request<ImageData>(`user/image_${day}`);
|
|
||||||
this.multi_modal!.show_image(day_image.data_url, name_door(day));
|
|
||||||
} catch (error) {
|
|
||||||
this.multi_modal!.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,292 +0,0 @@
|
||||||
<template>
|
|
||||||
<BulmaDrawer header="Konfiguration" @open="on_open" refreshable>
|
|
||||||
<div class="card-content">
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column is-one-third">
|
|
||||||
<div class="content">
|
|
||||||
<h3>Lösung</h3>
|
|
||||||
<dl>
|
|
||||||
<dt>Wert</dt>
|
|
||||||
<dd>
|
|
||||||
Eingabe:
|
|
||||||
<span class="is-family-monospace">
|
|
||||||
"{{ admin_config_model.solution.value }}"
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
<dd>
|
|
||||||
Ausgabe:
|
|
||||||
<span class="is-family-monospace">
|
|
||||||
"{{ admin_config_model.solution.clean }}"
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt>Transformation</dt>
|
|
||||||
<dd>
|
|
||||||
Whitespace:
|
|
||||||
<span class="is-uppercase is-family-monospace">
|
|
||||||
{{ admin_config_model.solution.whitespace }}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
<dd>
|
|
||||||
Sonderzeichen:
|
|
||||||
<span class="is-uppercase is-family-monospace">
|
|
||||||
{{ admin_config_model.solution.special_chars }}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
<dd>
|
|
||||||
Buchstaben:
|
|
||||||
<span class="is-uppercase is-family-monospace">
|
|
||||||
{{ admin_config_model.solution.case }}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<h3>Rätsel</h3>
|
|
||||||
<dl>
|
|
||||||
<dt>Offene Türchen</dt>
|
|
||||||
<dd>{{ store.user_doors.length }}</dd>
|
|
||||||
|
|
||||||
<dt>Zeit zum nächsten Türchen</dt>
|
|
||||||
<dd v-if="store.next_door_target === null">
|
|
||||||
Kein nächstes Türchen
|
|
||||||
</dd>
|
|
||||||
<dd v-else><CountDown :until="store.next_door_target" /></dd>
|
|
||||||
|
|
||||||
<dt>Erstes Türchen</dt>
|
|
||||||
<dd>{{ fmt_puzzle_date("first") }}</dd>
|
|
||||||
|
|
||||||
<dt>Nächstes Türchen</dt>
|
|
||||||
<dd>{{ fmt_puzzle_date("next") }}</dd>
|
|
||||||
|
|
||||||
<dt>Letztes Türchen</dt>
|
|
||||||
<dd>{{ fmt_puzzle_date("last") }}</dd>
|
|
||||||
|
|
||||||
<dt>Rätsel schließt nach</dt>
|
|
||||||
<dd>{{ fmt_puzzle_date("end") }}</dd>
|
|
||||||
|
|
||||||
<dt>Zufalls-Seed</dt>
|
|
||||||
<dd class="is-family-monospace">
|
|
||||||
"{{ admin_config_model.puzzle.seed }}"
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt>Extra-Tage</dt>
|
|
||||||
<dd>
|
|
||||||
<template
|
|
||||||
v-for="(day, index) in admin_config_model.puzzle.extra_days"
|
|
||||||
:key="`extra_day-${index}`"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<template v-if="index > 0">, </template>
|
|
||||||
{{ day }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt>Leere Türchen</dt>
|
|
||||||
<dd v-if="admin_config_model.puzzle.skip_empty">Überspringen</dd>
|
|
||||||
<dd v-else>Anzeigen</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-one-third">
|
|
||||||
<div class="content">
|
|
||||||
<h3>Kalender</h3>
|
|
||||||
<dl>
|
|
||||||
<dt>Definition</dt>
|
|
||||||
<dd>{{ admin_config_model.calendar.config_file }}</dd>
|
|
||||||
|
|
||||||
<dt>Hintergrundbild</dt>
|
|
||||||
<dd>{{ admin_config_model.calendar.background }}</dd>
|
|
||||||
|
|
||||||
<dt>Favicon</dt>
|
|
||||||
<dd>{{ admin_config_model.calendar.favicon }}</dd>
|
|
||||||
|
|
||||||
<dt>Türchen ({{ doors.length }} Stück)</dt>
|
|
||||||
<dd>
|
|
||||||
<template v-for="(door, index) in doors" :key="`door-${index}`">
|
|
||||||
<span>
|
|
||||||
<template v-if="index > 0">, </template>
|
|
||||||
{{ door.day }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<h3>Bilder</h3>
|
|
||||||
<dl>
|
|
||||||
<dt>Größe</dt>
|
|
||||||
<dd>{{ admin_config_model.image.size }} px</dd>
|
|
||||||
|
|
||||||
<dt>Rand</dt>
|
|
||||||
<dd>{{ admin_config_model.image.border }} px</dd>
|
|
||||||
|
|
||||||
<dt>Schriftarten</dt>
|
|
||||||
<dd
|
|
||||||
v-for="(font, index) in admin_config_model.fonts"
|
|
||||||
:key="`font-${index}`"
|
|
||||||
>
|
|
||||||
{{ font.file }} ({{ font.size }} pt)
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-one-third">
|
|
||||||
<div class="content">
|
|
||||||
<h3>WebDAV</h3>
|
|
||||||
<dl>
|
|
||||||
<dt>URL</dt>
|
|
||||||
<dd>{{ admin_config_model.webdav.url }}</dd>
|
|
||||||
|
|
||||||
<dt>Zugangsdaten</dt>
|
|
||||||
<dd class="is-family-monospace">
|
|
||||||
<BulmaSecret @load="load_dav_credentials">
|
|
||||||
<span class="tag is-danger">user</span>
|
|
||||||
{{ dav_credentials[0] }}
|
|
||||||
<br />
|
|
||||||
<span class="tag is-danger">pass</span>
|
|
||||||
{{ dav_credentials[1] }}
|
|
||||||
</BulmaSecret>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt>Cache-Dauer</dt>
|
|
||||||
<dd>{{ admin_config_model.webdav.cache_ttl }} s</dd>
|
|
||||||
|
|
||||||
<dt>Konfigurationsdatei</dt>
|
|
||||||
<dd>{{ admin_config_model.webdav.config_file }}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<h3>Sonstige</h3>
|
|
||||||
<dl>
|
|
||||||
<dt>Redis</dt>
|
|
||||||
<dd>Host: {{ admin_config_model.redis.host }}</dd>
|
|
||||||
<dd>Port: {{ admin_config_model.redis.port }}</dd>
|
|
||||||
<dd>Datenbank: {{ admin_config_model.redis.db }}</dd>
|
|
||||||
<dd>Protokoll: {{ admin_config_model.redis.protocol }}</dd>
|
|
||||||
|
|
||||||
<dt>UI-Admin</dt>
|
|
||||||
<dd class="is-family-monospace">
|
|
||||||
<BulmaSecret @load="load_ui_credentials">
|
|
||||||
<span class="tag is-danger">user</span>
|
|
||||||
{{ ui_credentials[0] }}
|
|
||||||
<br />
|
|
||||||
<span class="tag is-danger">pass</span>
|
|
||||||
{{ ui_credentials[1] }}
|
|
||||||
</BulmaSecret>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</BulmaDrawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { AdminConfigModel, Credentials, DoorSaved } from "@/lib/model";
|
|
||||||
import { advent22Store } from "@/lib/store";
|
|
||||||
import { DateTime } from "luxon";
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
|
|
||||||
import { API } from "@/lib/api";
|
|
||||||
import BulmaDrawer from "../bulma/Drawer.vue";
|
|
||||||
import BulmaSecret from "../bulma/Secret.vue";
|
|
||||||
import CountDown from "../CountDown.vue";
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
BulmaDrawer,
|
|
||||||
BulmaSecret,
|
|
||||||
CountDown,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
public readonly store = advent22Store();
|
|
||||||
|
|
||||||
public admin_config_model: AdminConfigModel = {
|
|
||||||
solution: {
|
|
||||||
value: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
|
||||||
whitespace: "KEEP",
|
|
||||||
special_chars: "KEEP",
|
|
||||||
case: "KEEP",
|
|
||||||
clean: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
|
||||||
},
|
|
||||||
puzzle: {
|
|
||||||
first: "2023-12-01",
|
|
||||||
next: "2023-12-01",
|
|
||||||
last: "2023-12-24",
|
|
||||||
end: "2024-04-01",
|
|
||||||
seed: "",
|
|
||||||
extra_days: [],
|
|
||||||
skip_empty: true,
|
|
||||||
},
|
|
||||||
calendar: {
|
|
||||||
config_file: "lorem ipsum",
|
|
||||||
background: "dolor sit",
|
|
||||||
favicon: "sit amet",
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
size: 500,
|
|
||||||
border: 0,
|
|
||||||
},
|
|
||||||
fonts: [{ file: "consetetur", size: 0 }],
|
|
||||||
redis: {
|
|
||||||
host: "0.0.0.0",
|
|
||||||
port: 6379,
|
|
||||||
db: 0,
|
|
||||||
protocol: 3,
|
|
||||||
},
|
|
||||||
webdav: {
|
|
||||||
url: "sadipscing elitr",
|
|
||||||
cache_ttl: 0,
|
|
||||||
config_file: "sed diam nonumy",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
public doors: DoorSaved[] = [];
|
|
||||||
public dav_credentials: Credentials = ["", ""];
|
|
||||||
public ui_credentials: Credentials = ["", ""];
|
|
||||||
|
|
||||||
public fmt_puzzle_date(name: keyof AdminConfigModel["puzzle"]): string {
|
|
||||||
const iso_date = this.admin_config_model.puzzle[name];
|
|
||||||
if (!(typeof iso_date == "string")) return "-";
|
|
||||||
|
|
||||||
return DateTime.fromISO(iso_date).toLocaleString(DateTime.DATE_SHORT);
|
|
||||||
}
|
|
||||||
|
|
||||||
public on_open(ready: () => void, fail: () => void): void {
|
|
||||||
Promise.all([
|
|
||||||
this.store.update(),
|
|
||||||
API.request<AdminConfigModel>("admin/config_model"),
|
|
||||||
API.request<DoorSaved[]>("admin/doors"),
|
|
||||||
])
|
|
||||||
.then(([store_update, admin_config_model, doors]) => {
|
|
||||||
store_update; // discard value
|
|
||||||
|
|
||||||
this.admin_config_model = admin_config_model;
|
|
||||||
this.doors = doors;
|
|
||||||
|
|
||||||
ready();
|
|
||||||
})
|
|
||||||
.catch(fail);
|
|
||||||
}
|
|
||||||
|
|
||||||
public load_dav_credentials(): void {
|
|
||||||
API.request<Credentials>("admin/dav_credentials")
|
|
||||||
.then((creds) => (this.dav_credentials = creds))
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
public load_ui_credentials(): void {
|
|
||||||
API.request<Credentials>("admin/ui_credentials")
|
|
||||||
.then((creds) => (this.ui_credentials = creds))
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
dd {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,196 +0,0 @@
|
||||||
<template>
|
|
||||||
<BulmaDrawer header="Türchen bearbeiten" @open="on_open">
|
|
||||||
<nav class="level is-mobile mb-0" style="overflow-x: auto">
|
|
||||||
<BulmaButton
|
|
||||||
:disabled="current_step === 0"
|
|
||||||
class="level-item is-link"
|
|
||||||
@click="current_step--"
|
|
||||||
icon="fa-solid fa-backward"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BulmaBreadcrumbs
|
|
||||||
:steps="steps"
|
|
||||||
v-model="current_step"
|
|
||||||
class="level-item mb-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BulmaButton
|
|
||||||
:disabled="current_step === 2"
|
|
||||||
class="level-item is-link"
|
|
||||||
@click="current_step++"
|
|
||||||
icon="fa-solid fa-forward"
|
|
||||||
/>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="card-content pb-0">
|
|
||||||
<div v-if="doors.length > 0" class="content">
|
|
||||||
<p>Für diese Tage ist ein Türchen vorhanden:</p>
|
|
||||||
<div class="tags">
|
|
||||||
<span
|
|
||||||
v-for="(door, index) in doors.toSorted((a, b) => a.day - b.day)"
|
|
||||||
:key="`door-${index}`"
|
|
||||||
class="tag is-primary"
|
|
||||||
>
|
|
||||||
{{ door.day }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DoorPlacer v-if="current_step === 0" :doors="doors" />
|
|
||||||
<DoorChooser v-if="current_step === 1" :doors="doors" />
|
|
||||||
<div v-if="current_step === 2" class="card-content">
|
|
||||||
<Calendar :doors="doors" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="card-footer is-flex is-justify-content-space-around">
|
|
||||||
<BulmaButton
|
|
||||||
class="card-footer-item is-danger"
|
|
||||||
@click="on_download"
|
|
||||||
icon="fa-solid fa-cloud-arrow-down"
|
|
||||||
:busy="loading_doors"
|
|
||||||
text="Laden"
|
|
||||||
/>
|
|
||||||
<BulmaButton
|
|
||||||
class="card-footer-item is-warning"
|
|
||||||
@click="on_discard"
|
|
||||||
icon="fa-solid fa-trash"
|
|
||||||
text="Löschen"
|
|
||||||
/>
|
|
||||||
<BulmaButton
|
|
||||||
class="card-footer-item is-success"
|
|
||||||
@click="on_upload"
|
|
||||||
icon="fa-solid fa-cloud-arrow-up"
|
|
||||||
:busy="saving_doors"
|
|
||||||
text="Speichern"
|
|
||||||
/>
|
|
||||||
</footer>
|
|
||||||
</BulmaDrawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Step } from "@/lib/helpers";
|
|
||||||
import { DoorSaved } from "@/lib/model";
|
|
||||||
import { Door } from "@/lib/rects/door";
|
|
||||||
import { advent22Store } from "@/lib/store";
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
|
|
||||||
import { API } from "@/lib/api";
|
|
||||||
import { APIError } from "@/lib/api_error";
|
|
||||||
import { toast } from "bulma-toast";
|
|
||||||
import Calendar from "../Calendar.vue";
|
|
||||||
import BulmaBreadcrumbs from "../bulma/Breadcrumbs.vue";
|
|
||||||
import BulmaButton from "../bulma/Button.vue";
|
|
||||||
import BulmaDrawer from "../bulma/Drawer.vue";
|
|
||||||
import DoorChooser from "../editor/DoorChooser.vue";
|
|
||||||
import DoorPlacer from "../editor/DoorPlacer.vue";
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
BulmaBreadcrumbs,
|
|
||||||
BulmaButton,
|
|
||||||
BulmaDrawer,
|
|
||||||
DoorPlacer,
|
|
||||||
DoorChooser,
|
|
||||||
Calendar,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
public readonly steps: Step[] = [
|
|
||||||
{ label: "Platzieren", icon: "fa-solid fa-crosshairs" },
|
|
||||||
{ label: "Ordnen", icon: "fa-solid fa-list-ol" },
|
|
||||||
{ label: "Vorschau", icon: "fa-solid fa-magnifying-glass" },
|
|
||||||
];
|
|
||||||
public current_step = 0;
|
|
||||||
public doors: Door[] = [];
|
|
||||||
private readonly store = advent22Store();
|
|
||||||
|
|
||||||
public loading_doors = false;
|
|
||||||
public saving_doors = false;
|
|
||||||
|
|
||||||
private load_doors(): Promise<void> {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
API.request<DoorSaved[]>("admin/doors")
|
|
||||||
.then((data) => {
|
|
||||||
this.doors.length = 0;
|
|
||||||
|
|
||||||
for (const value of data) {
|
|
||||||
this.doors.push(Door.load(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
APIError.alert(error);
|
|
||||||
reject();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private save_doors(): Promise<void> {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const data: DoorSaved[] = [];
|
|
||||||
|
|
||||||
for (const door of this.doors) {
|
|
||||||
data.push(door.save());
|
|
||||||
}
|
|
||||||
|
|
||||||
API.request<void>({ endpoint: "admin/doors", method: "PUT", data: data })
|
|
||||||
.then(resolve)
|
|
||||||
.catch((error) => {
|
|
||||||
APIError.alert(error);
|
|
||||||
reject();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public on_open(ready: () => void, fail: () => void): void {
|
|
||||||
this.load_doors().then(ready).catch(fail);
|
|
||||||
}
|
|
||||||
|
|
||||||
public on_download() {
|
|
||||||
if (confirm("Aktuelle Änderungen verwerfen und Status vom Server laden?")) {
|
|
||||||
this.loading_doors = true;
|
|
||||||
|
|
||||||
this.load_doors()
|
|
||||||
.then(() =>
|
|
||||||
toast({
|
|
||||||
message: "Erfolgreich!",
|
|
||||||
type: "is-success",
|
|
||||||
duration: 2e3,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => (this.loading_doors = false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public on_discard() {
|
|
||||||
if (confirm("Alle Türchen löschen? (nur lokal)")) {
|
|
||||||
// empty `doors` array
|
|
||||||
this.doors.length = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public on_upload() {
|
|
||||||
if (confirm("Aktuelle Änderungen an den Server schicken?")) {
|
|
||||||
this.saving_doors = true;
|
|
||||||
|
|
||||||
this.save_doors()
|
|
||||||
.then(() => {
|
|
||||||
this.load_doors()
|
|
||||||
.then(() =>
|
|
||||||
toast({
|
|
||||||
message: "Erfolgreich!",
|
|
||||||
type: "is-success",
|
|
||||||
duration: 2e3,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => (this.saving_doors = false));
|
|
||||||
})
|
|
||||||
.catch(() => (this.saving_doors = false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,39 +0,0 @@
|
||||||
<!-- eslint-disable vue/multi-word-component-names -->
|
|
||||||
<template>
|
|
||||||
<nav class="breadcrumb has-succeeds-separator">
|
|
||||||
<ul>
|
|
||||||
<li
|
|
||||||
v-for="(step, index) in steps"
|
|
||||||
:key="index"
|
|
||||||
:class="modelValue === index ? 'is-active' : ''"
|
|
||||||
@click.left="change_step(index)"
|
|
||||||
>
|
|
||||||
<a>
|
|
||||||
<span class="icon is-small">
|
|
||||||
<FontAwesomeIcon :icon="step.icon" />
|
|
||||||
</span>
|
|
||||||
<span>{{ step.label }}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Step } from "@/lib/helpers";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
steps: Step[];
|
|
||||||
modelValue: number;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
"update:modelValue": [number];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
function change_step(next_step: number) {
|
|
||||||
if (next_step === props.modelValue) return;
|
|
||||||
|
|
||||||
emit("update:modelValue", next_step);
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,28 +0,0 @@
|
||||||
<!-- eslint-disable vue/multi-word-component-names -->
|
|
||||||
<template>
|
|
||||||
<button class="button">
|
|
||||||
<slot name="default">
|
|
||||||
<span v-if="icon !== undefined" class="icon">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
v-if="icon !== undefined"
|
|
||||||
:icon="icon"
|
|
||||||
:beat-fade="busy"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</slot>
|
|
||||||
<span v-if="text !== undefined">{{ text }}</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
icon?: string | string[];
|
|
||||||
text?: string;
|
|
||||||
busy?: boolean;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
busy: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
</script>
|
|
|
@ -1,89 +0,0 @@
|
||||||
<!-- eslint-disable vue/multi-word-component-names -->
|
|
||||||
<template>
|
|
||||||
<div class="card">
|
|
||||||
<header class="card-header is-unselectable" style="cursor: pointer">
|
|
||||||
<p class="card-header-title" @click="toggle">{{ header }}</p>
|
|
||||||
|
|
||||||
<p v-if="refreshable" class="card-header-icon px-0">
|
|
||||||
<BulmaButton class="is-small is-primary" @click="refresh">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
:icon="['fas', 'arrows-rotate']"
|
|
||||||
:spin="is_open && state === 'loading'"
|
|
||||||
/>
|
|
||||||
</BulmaButton>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button class="card-header-icon" @click="toggle">
|
|
||||||
<span class="icon">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
:icon="['fas', is_open ? 'angle-down' : 'angle-right']"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<template v-if="is_open">
|
|
||||||
<div v-if="state === 'loading'" class="card-content">
|
|
||||||
<progress class="progress is-primary" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="state === 'failed'"
|
|
||||||
class="card-content has-text-danger has-text-centered"
|
|
||||||
>
|
|
||||||
<span class="icon is-large">
|
|
||||||
<FontAwesomeIcon :icon="['fas', 'ban']" size="3x" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<slot v-else name="default" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from "vue";
|
|
||||||
import BulmaButton from "./Button.vue";
|
|
||||||
|
|
||||||
withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
header: string;
|
|
||||||
refreshable?: boolean;
|
|
||||||
}>(),
|
|
||||||
{ refreshable: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
open: [
|
|
||||||
{
|
|
||||||
ready(): void;
|
|
||||||
fail(): void;
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const is_open = ref(false);
|
|
||||||
const state = ref<"loading" | "ready" | "failed">("loading");
|
|
||||||
|
|
||||||
function toggle() {
|
|
||||||
is_open.value = !is_open.value;
|
|
||||||
|
|
||||||
if (is_open.value) {
|
|
||||||
state.value = "loading";
|
|
||||||
|
|
||||||
emit("open", {
|
|
||||||
ready: () => (state.value = "ready"),
|
|
||||||
fail: () => (state.value = "failed"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
is_open.value = false;
|
|
||||||
toggle();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
div.card:not(:last-child) {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,47 +0,0 @@
|
||||||
<!-- eslint-disable vue/multi-word-component-names -->
|
|
||||||
<template>
|
|
||||||
<slot v-if="state === 'show'" name="default" />
|
|
||||||
<span v-else>***</span>
|
|
||||||
<BulmaButton
|
|
||||||
:class="`is-small is-${button_class} ml-2`"
|
|
||||||
:icon="['fas', `${button_icon}`]"
|
|
||||||
:busy="state === 'click'"
|
|
||||||
@click="on_click"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from "vue";
|
|
||||||
import BulmaButton from "./Button.vue";
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
load: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const state = ref<"start" | "click" | "show">("start");
|
|
||||||
const button_class = ref<"primary" | "warning" | "danger">("primary");
|
|
||||||
const button_icon = ref<"eye-slash" | "eye">("eye-slash");
|
|
||||||
|
|
||||||
function on_click(): void {
|
|
||||||
switch (state.value) {
|
|
||||||
case "show":
|
|
||||||
state.value = "start";
|
|
||||||
button_class.value = "primary";
|
|
||||||
button_icon.value = "eye-slash";
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "start":
|
|
||||||
state.value = "click";
|
|
||||||
button_class.value = "warning";
|
|
||||||
button_icon.value = "eye-slash";
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "click":
|
|
||||||
state.value = "show";
|
|
||||||
button_class.value = "danger";
|
|
||||||
button_icon.value = "eye";
|
|
||||||
emit("load");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,49 +0,0 @@
|
||||||
<!-- eslint-disable vue/multi-word-component-names -->
|
|
||||||
<template>
|
|
||||||
<div style="display: none">
|
|
||||||
<div v-bind="$attrs" ref="message">
|
|
||||||
<slot name="default" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import * as bulmaToast from "bulma-toast";
|
|
||||||
import { onMounted, ref } from "vue";
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
handle: [
|
|
||||||
{
|
|
||||||
show(options: bulmaToast.Options): void;
|
|
||||||
hide(): void;
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const message = ref<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
onMounted(() =>
|
|
||||||
emit("handle", {
|
|
||||||
show(options: bulmaToast.Options = {}) {
|
|
||||||
if (!(message.value instanceof HTMLElement)) return;
|
|
||||||
|
|
||||||
bulmaToast.toast({
|
|
||||||
...options,
|
|
||||||
single: true,
|
|
||||||
message: message.value,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
hide() {
|
|
||||||
if (!(message.value instanceof HTMLElement)) return;
|
|
||||||
|
|
||||||
const toast_div = message.value.parentElement;
|
|
||||||
if (!(toast_div instanceof HTMLDivElement)) return;
|
|
||||||
|
|
||||||
const dbutton = toast_div.querySelector("button.delete");
|
|
||||||
if (!(dbutton instanceof HTMLButtonElement)) return;
|
|
||||||
|
|
||||||
dbutton.click();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
</script>
|
|
|
@ -1,41 +0,0 @@
|
||||||
<template>
|
|
||||||
<SVGRect
|
|
||||||
variant="primary"
|
|
||||||
:visible="store.is_touch_device || force_visible"
|
|
||||||
:rectangle="door.position"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="has-text-danger"
|
|
||||||
style="text-shadow: 0 0 10px white, 0 0 20px white"
|
|
||||||
>
|
|
||||||
{{ door.day }}
|
|
||||||
</div>
|
|
||||||
</SVGRect>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Door } from "@/lib/rects/door";
|
|
||||||
import { advent22Store } from "@/lib/store";
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
|
|
||||||
import SVGRect from "./SVGRect.vue";
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
SVGRect,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
door: Door,
|
|
||||||
force_visible: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
public readonly store = advent22Store();
|
|
||||||
|
|
||||||
public door!: Door;
|
|
||||||
public force_visible!: boolean;
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,76 +0,0 @@
|
||||||
<template>
|
|
||||||
<foreignObject
|
|
||||||
:x="Math.round(get_bg_aspect_ratio() * rectangle.left)"
|
|
||||||
:y="rectangle.top"
|
|
||||||
:width="Math.round(get_bg_aspect_ratio() * rectangle.width)"
|
|
||||||
:height="rectangle.height"
|
|
||||||
:style="`transform: scaleX(${1 / get_bg_aspect_ratio()})`"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
xmlns="http://www.w3.org/1999/xhtml"
|
|
||||||
:class="`px-2 is-flex is-align-items-center is-justify-content-center is-size-2 has-text-weight-bold ${variant} ${
|
|
||||||
visible ? 'visible' : ''
|
|
||||||
}`"
|
|
||||||
style="height: inherit"
|
|
||||||
v-bind="$attrs"
|
|
||||||
>
|
|
||||||
<slot name="default" />
|
|
||||||
</div>
|
|
||||||
</foreignObject>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { loading_success } from "@/lib/helpers";
|
|
||||||
import { Rectangle } from "@/lib/rects/rectangle";
|
|
||||||
import { advent22Store } from "@/lib/store";
|
|
||||||
|
|
||||||
const store = advent22Store();
|
|
||||||
|
|
||||||
type BulmaVariant =
|
|
||||||
| "primary"
|
|
||||||
| "link"
|
|
||||||
| "info"
|
|
||||||
| "success"
|
|
||||||
| "warning"
|
|
||||||
| "danger";
|
|
||||||
|
|
||||||
withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
variant: BulmaVariant;
|
|
||||||
visible?: boolean;
|
|
||||||
rectangle: Rectangle;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
visible: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function get_bg_aspect_ratio(): number {
|
|
||||||
if (!loading_success(store.background_image)) return 1;
|
|
||||||
|
|
||||||
return store.background_image.height / store.background_image.width;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
@use "@/bulma-scheme" as scheme;
|
|
||||||
|
|
||||||
foreignObject > div {
|
|
||||||
&:not(.visible, :hover):deep() > * {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.visible,
|
|
||||||
&:hover {
|
|
||||||
border-width: 2px;
|
|
||||||
border-style: solid;
|
|
||||||
|
|
||||||
@each $name, $color in scheme.$colors {
|
|
||||||
&.#{$name} {
|
|
||||||
background-color: rgba($color, 0.3);
|
|
||||||
border-color: rgba($color, 0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,62 +0,0 @@
|
||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
version="1.1"
|
|
||||||
viewBox="0 0 1000 1000"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
@contextmenu.prevent
|
|
||||||
@mousedown="transform_mouse_event"
|
|
||||||
@mousemove="transform_mouse_event"
|
|
||||||
@mouseup="transform_mouse_event"
|
|
||||||
@click="transform_mouse_event"
|
|
||||||
@dblclick="transform_mouse_event"
|
|
||||||
>
|
|
||||||
<slot name="default" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Vector2D } from "@/lib/rects/vector2d";
|
|
||||||
|
|
||||||
function get_event_thous(event: MouseEvent): Vector2D {
|
|
||||||
if (!(event.currentTarget instanceof SVGSVGElement)) {
|
|
||||||
return new Vector2D();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Vector2D(
|
|
||||||
Math.round((event.offsetX / event.currentTarget.clientWidth) * 1000),
|
|
||||||
Math.round((event.offsetY / event.currentTarget.clientHeight) * 1000),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCMouseEvent = [MouseEvent, Vector2D];
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
mousedown: TCMouseEvent;
|
|
||||||
mouseup: TCMouseEvent;
|
|
||||||
mousemove: TCMouseEvent;
|
|
||||||
click: TCMouseEvent;
|
|
||||||
dblclick: TCMouseEvent;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
function transform_mouse_event(event: MouseEvent) {
|
|
||||||
const point = get_event_thous(event);
|
|
||||||
|
|
||||||
// mute a useless typescript error
|
|
||||||
const event_type = event.type as "mousedown";
|
|
||||||
emit(event_type, event, point);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
svg {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
|
|
||||||
z-index: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,157 +0,0 @@
|
||||||
<template>
|
|
||||||
<ThouCanvas
|
|
||||||
@mousedown.left="draw_start"
|
|
||||||
@mouseup.left="draw_finish"
|
|
||||||
@mousedown.right="drag_start"
|
|
||||||
@mouseup.right="drag_finish"
|
|
||||||
@mousemove="on_mousemove"
|
|
||||||
@click.middle="remove_rect"
|
|
||||||
@dblclick.left="remove_rect"
|
|
||||||
>
|
|
||||||
<CalendarDoor
|
|
||||||
v-for="(door, index) in doors"
|
|
||||||
:key="`door-${index}`"
|
|
||||||
:door="door"
|
|
||||||
force_visible
|
|
||||||
/>
|
|
||||||
<SVGRect
|
|
||||||
v-if="preview_visible"
|
|
||||||
variant="success"
|
|
||||||
:rectangle="preview_rect"
|
|
||||||
visible
|
|
||||||
/>
|
|
||||||
</ThouCanvas>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Door } from "@/lib/rects/door";
|
|
||||||
import { Rectangle } from "@/lib/rects/rectangle";
|
|
||||||
import { Vector2D } from "@/lib/rects/vector2d";
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
|
|
||||||
import CalendarDoor from "../calendar/CalendarDoor.vue";
|
|
||||||
import SVGRect from "../calendar/SVGRect.vue";
|
|
||||||
import ThouCanvas from "../calendar/ThouCanvas.vue";
|
|
||||||
|
|
||||||
enum CanvasState {
|
|
||||||
Idle,
|
|
||||||
Drawing,
|
|
||||||
Dragging,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
CalendarDoor,
|
|
||||||
SVGRect,
|
|
||||||
ThouCanvas,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
doors: Array,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
private readonly min_rect_area = 300;
|
|
||||||
private state = CanvasState.Idle;
|
|
||||||
public preview_rect = new Rectangle();
|
|
||||||
private drag_door?: Door;
|
|
||||||
private drag_origin = new Vector2D();
|
|
||||||
public doors!: Door[];
|
|
||||||
|
|
||||||
public get preview_visible(): boolean {
|
|
||||||
return this.state !== CanvasState.Idle;
|
|
||||||
}
|
|
||||||
|
|
||||||
private pop_door(point: Vector2D): Door | undefined {
|
|
||||||
const idx = this.doors.findIndex((rect) => rect.position.contains(point));
|
|
||||||
|
|
||||||
if (idx === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.doors.splice(idx, 1)[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
public draw_start(event: MouseEvent, point: Vector2D) {
|
|
||||||
if (this.preview_visible) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state = CanvasState.Drawing;
|
|
||||||
this.preview_rect = new Rectangle(point, point);
|
|
||||||
}
|
|
||||||
|
|
||||||
public draw_finish() {
|
|
||||||
if (this.state !== CanvasState.Drawing || this.preview_rect === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state = CanvasState.Idle;
|
|
||||||
|
|
||||||
if (this.preview_rect.area < this.min_rect_area) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.doors.push(new Door(this.preview_rect));
|
|
||||||
}
|
|
||||||
|
|
||||||
public drag_start(event: MouseEvent, point: Vector2D) {
|
|
||||||
if (this.preview_visible) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.drag_door = this.pop_door(point);
|
|
||||||
|
|
||||||
if (this.drag_door === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state = CanvasState.Dragging;
|
|
||||||
this.drag_origin = point;
|
|
||||||
|
|
||||||
this.preview_rect = this.drag_door.position;
|
|
||||||
}
|
|
||||||
|
|
||||||
public drag_finish() {
|
|
||||||
if (
|
|
||||||
this.state !== CanvasState.Dragging ||
|
|
||||||
this.preview_rect === undefined
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state = CanvasState.Idle;
|
|
||||||
this.doors.push(new Door(this.preview_rect, this.drag_door!.day));
|
|
||||||
}
|
|
||||||
|
|
||||||
public on_mousemove(event: MouseEvent, point: Vector2D) {
|
|
||||||
if (this.preview_rect === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state === CanvasState.Drawing) {
|
|
||||||
this.preview_rect = this.preview_rect.update(undefined, point);
|
|
||||||
} else if (this.state === CanvasState.Dragging && this.drag_door) {
|
|
||||||
const movement = point.minus(this.drag_origin);
|
|
||||||
this.preview_rect = this.drag_door.position.move(movement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public remove_rect(event: MouseEvent, point: Vector2D) {
|
|
||||||
if (this.preview_visible) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pop_door(point);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
svg {
|
|
||||||
cursor: crosshair;
|
|
||||||
|
|
||||||
* {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,52 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="card-content">
|
|
||||||
<div class="content is-small">
|
|
||||||
<h3>Steuerung</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Linksklick: Türchen bearbeiten</li>
|
|
||||||
<li>Tastatur: Tag eingeben</li>
|
|
||||||
<li>[Enter]: Tag speichern</li>
|
|
||||||
<li>[Esc]: Eingabe Abbrechen</li>
|
|
||||||
<li>[Entf]: Tag entfernen</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<figure class="image is-unselectable">
|
|
||||||
<img :src="_ensure_loaded(store.background_image).data_url" />
|
|
||||||
<ThouCanvas>
|
|
||||||
<PreviewDoor
|
|
||||||
v-for="(door, index) in doors"
|
|
||||||
:key="`door-${index}`"
|
|
||||||
:door="door"
|
|
||||||
/>
|
|
||||||
</ThouCanvas>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { ensure_loaded, Loading } from "@/lib/helpers";
|
|
||||||
import { Door } from "@/lib/rects/door";
|
|
||||||
import { advent22Store } from "@/lib/store";
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
|
|
||||||
import ThouCanvas from "../calendar/ThouCanvas.vue";
|
|
||||||
import PreviewDoor from "./PreviewDoor.vue";
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
ThouCanvas,
|
|
||||||
PreviewDoor,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
doors: Array,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
public doors!: Door[];
|
|
||||||
public readonly store = advent22Store();
|
|
||||||
|
|
||||||
public _ensure_loaded<T>(o: Loading<T>): T {
|
|
||||||
return ensure_loaded(o);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,42 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="card-content">
|
|
||||||
<div class="content is-small">
|
|
||||||
<h3>Steuerung</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Linksklick + Ziehen: Neues Türchen erstellen</li>
|
|
||||||
<li>Rechtsklick + Ziehen: Türchen verschieben</li>
|
|
||||||
<li>Doppel- oder Mittelklick: Türchen löschen</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<figure class="image is-unselectable">
|
|
||||||
<img :src="_ensure_loaded(store.background_image).data_url" />
|
|
||||||
<DoorCanvas :doors="doors" />
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { ensure_loaded, Loading } from "@/lib/helpers";
|
|
||||||
import { Door } from "@/lib/rects/door";
|
|
||||||
import { advent22Store } from "@/lib/store";
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
|
|
||||||
import DoorCanvas from "./DoorCanvas.vue";
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
DoorCanvas,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
doors: Array,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
public doors!: Door[];
|
|
||||||
public readonly store = advent22Store();
|
|
||||||
|
|
||||||
public _ensure_loaded<T>(o: Loading<T>): T {
|
|
||||||
return ensure_loaded(o);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,89 +0,0 @@
|
||||||
<template>
|
|
||||||
<SVGRect
|
|
||||||
style="cursor: text"
|
|
||||||
:rectangle="door.position"
|
|
||||||
:variant="editing ? 'success' : 'primary'"
|
|
||||||
@click.left="on_click"
|
|
||||||
visible
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-if="editing"
|
|
||||||
v-model="day_str"
|
|
||||||
ref="day_input"
|
|
||||||
class="input is-large"
|
|
||||||
type="number"
|
|
||||||
:min="MIN_DAY"
|
|
||||||
placeholder="Tag"
|
|
||||||
@keydown="on_keydown"
|
|
||||||
/>
|
|
||||||
<div v-else class="has-text-danger">
|
|
||||||
{{ door.day > 0 ? door.day : "*" }}
|
|
||||||
</div>
|
|
||||||
</SVGRect>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Door } from "@/lib/rects/door";
|
|
||||||
import { Options, Vue } from "vue-class-component";
|
|
||||||
|
|
||||||
import SVGRect from "../calendar/SVGRect.vue";
|
|
||||||
|
|
||||||
@Options({
|
|
||||||
components: {
|
|
||||||
SVGRect,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
door: Door,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class extends Vue {
|
|
||||||
public door!: Door;
|
|
||||||
public readonly MIN_DAY = Door.MIN_DAY;
|
|
||||||
|
|
||||||
public day_str = "";
|
|
||||||
public editing = false;
|
|
||||||
|
|
||||||
private toggle_editing() {
|
|
||||||
this.day_str = String(this.door.day);
|
|
||||||
this.editing = !this.editing;
|
|
||||||
}
|
|
||||||
|
|
||||||
public on_click(event: MouseEvent) {
|
|
||||||
if (!(event.target instanceof HTMLDivElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.editing) {
|
|
||||||
const day_input_focus = () => {
|
|
||||||
if (this.$refs.day_input instanceof HTMLInputElement) {
|
|
||||||
this.$refs.day_input.select();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$nextTick(day_input_focus);
|
|
||||||
};
|
|
||||||
day_input_focus();
|
|
||||||
} else {
|
|
||||||
this.door.day = this.day_str;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.toggle_editing();
|
|
||||||
}
|
|
||||||
|
|
||||||
public on_keydown(event: KeyboardEvent) {
|
|
||||||
if (!this.editing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
this.door.day = this.day_str;
|
|
||||||
this.toggle_editing();
|
|
||||||
} else if (event.key === "Delete") {
|
|
||||||
this.door.day = -1;
|
|
||||||
this.toggle_editing();
|
|
||||||
} else if (event.key === "Escape") {
|
|
||||||
this.toggle_editing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
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,82 +0,0 @@
|
||||||
import axios, {
|
|
||||||
AxiosBasicCredentials,
|
|
||||||
type AxiosRequestConfig,
|
|
||||||
type Method,
|
|
||||||
type RawAxiosRequestHeaders,
|
|
||||||
} from "axios";
|
|
||||||
import { APIError } from "./api_error";
|
|
||||||
|
|
||||||
interface Params {
|
|
||||||
endpoint: string;
|
|
||||||
method?: Method;
|
|
||||||
data?: unknown;
|
|
||||||
headers?: RawAxiosRequestHeaders;
|
|
||||||
config?: AxiosRequestConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class API {
|
|
||||||
private static get api_baseurl(): string {
|
|
||||||
// in production mode, return "proto://hostname/api"
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
|
||||||
return `${window.location.protocol}//${window.location.host}/api`;
|
|
||||||
} else if (process.env.NODE_ENV !== "development") {
|
|
||||||
// not in prouction or development mode
|
|
||||||
console.warn("Unexpected NODE_ENV value");
|
|
||||||
}
|
|
||||||
|
|
||||||
// in development mode, return "proto://hostname:8000/api"
|
|
||||||
return `${window.location.protocol}//${window.location.hostname}:8000/api`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly axios = axios.create({
|
|
||||||
timeout: 10e3,
|
|
||||||
baseURL: this.api_baseurl,
|
|
||||||
});
|
|
||||||
|
|
||||||
private static readonly storage_key = "advent22/credentials";
|
|
||||||
|
|
||||||
public static set creds(value: AxiosBasicCredentials | null) {
|
|
||||||
if (value === null) {
|
|
||||||
localStorage.removeItem(this.storage_key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(this.storage_key, JSON.stringify(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static get creds(): AxiosBasicCredentials | undefined {
|
|
||||||
const auth_json = localStorage.getItem(this.storage_key);
|
|
||||||
if (auth_json !== null) return JSON.parse(auth_json);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static get_axios_config({
|
|
||||||
endpoint,
|
|
||||||
method = "GET",
|
|
||||||
data,
|
|
||||||
headers = {},
|
|
||||||
config = {},
|
|
||||||
}: Params): AxiosRequestConfig {
|
|
||||||
return {
|
|
||||||
url: endpoint,
|
|
||||||
method: method,
|
|
||||||
data: data,
|
|
||||||
auth: this.creds,
|
|
||||||
headers: headers,
|
|
||||||
...config,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async request<T = string>(p: Params): Promise<T>;
|
|
||||||
public static async request<T = string>(p: string): Promise<T>;
|
|
||||||
public static async request<T = string>(p: Params | string): Promise<T> {
|
|
||||||
if (typeof p === "string") p = { endpoint: p };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.axios.request<T>(this.get_axios_config(p));
|
|
||||||
return response.data;
|
|
||||||
} catch (reason) {
|
|
||||||
console.error(`Failed to query ${p.endpoint}: ${reason}`);
|
|
||||||
throw new APIError(reason, p.endpoint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,77 +0,0 @@
|
||||||
import { AxiosError } from "axios";
|
|
||||||
import { toast } from "bulma-toast";
|
|
||||||
|
|
||||||
export class APIError extends Error {
|
|
||||||
reason: unknown;
|
|
||||||
axios_error: AxiosError | null = null;
|
|
||||||
|
|
||||||
constructor(reason: unknown, endpoint: string) {
|
|
||||||
super(endpoint); // sets this.message to the endpoint
|
|
||||||
this.reason = reason;
|
|
||||||
Object.setPrototypeOf(this, APIError.prototype);
|
|
||||||
|
|
||||||
if (reason instanceof AxiosError) {
|
|
||||||
this.axios_error = reason;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public format(): string {
|
|
||||||
let msg =
|
|
||||||
"Unbekannter Fehler, bitte wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
|
|
||||||
let code = "U";
|
|
||||||
const result = () => `${msg} (Fehlercode: ${code}/${this.message})`;
|
|
||||||
|
|
||||||
if (this.axios_error === null) return result();
|
|
||||||
|
|
||||||
switch (this.axios_error.code) {
|
|
||||||
case "ECONNABORTED":
|
|
||||||
// API unerreichbar
|
|
||||||
msg =
|
|
||||||
"API antwortet nicht, bitte später wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
|
|
||||||
code = "D";
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "ERR_NETWORK":
|
|
||||||
// Netzwerk nicht verbunden
|
|
||||||
msg = "Sieht aus, als sei deine Netzwerkverbindung gestört.";
|
|
||||||
code = "N";
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
if (this.axios_error.response === undefined) return result();
|
|
||||||
|
|
||||||
switch (this.axios_error.response.status) {
|
|
||||||
case 401:
|
|
||||||
// UNAUTHORIZED
|
|
||||||
msg = "Netter Versuch :)";
|
|
||||||
code = "A";
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 422:
|
|
||||||
// UNPROCESSABLE ENTITY
|
|
||||||
msg = "Funktion ist kaputt, bitte Admin benachrichtigen!";
|
|
||||||
code = "I";
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// HTTP
|
|
||||||
code = `H${this.axios_error.response.status}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result();
|
|
||||||
}
|
|
||||||
|
|
||||||
public alert() {
|
|
||||||
toast({
|
|
||||||
message: this.format(),
|
|
||||||
type: "is-danger",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static alert(error: unknown) {
|
|
||||||
new APIError(error, "").alert();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,44 +0,0 @@
|
||||||
import { APIError } from "./api_error";
|
|
||||||
|
|
||||||
export function objForEach<T>(
|
|
||||||
obj: T,
|
|
||||||
f: (k: keyof T, v: T[keyof T]) => void,
|
|
||||||
): void {
|
|
||||||
for (const k in obj) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(obj, k)) {
|
|
||||||
f(k, obj[k]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Loading<T> = T | "loading" | "error";
|
|
||||||
|
|
||||||
export function loading_success<T>(o: Loading<T>): o is T {
|
|
||||||
if (o === "loading") return false;
|
|
||||||
if (o === "error") return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensure_loaded<T>(o: Loading<T>): T {
|
|
||||||
if (!loading_success(o)) throw "";
|
|
||||||
|
|
||||||
return o;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handle_error(error: unknown) {
|
|
||||||
if (error instanceof APIError) {
|
|
||||||
error.alert();
|
|
||||||
} else {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function name_door(day: number): string {
|
|
||||||
return `Türchen ${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Step {
|
|
||||||
label: string;
|
|
||||||
icon: string | string[];
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
export interface AdminConfigModel {
|
|
||||||
solution: {
|
|
||||||
value: string;
|
|
||||||
whitespace: string;
|
|
||||||
special_chars: string;
|
|
||||||
case: string;
|
|
||||||
clean: string;
|
|
||||||
};
|
|
||||||
puzzle: {
|
|
||||||
first: string;
|
|
||||||
next: string | null;
|
|
||||||
last: string;
|
|
||||||
end: string;
|
|
||||||
seed: string;
|
|
||||||
extra_days: number[];
|
|
||||||
skip_empty: boolean;
|
|
||||||
};
|
|
||||||
calendar: {
|
|
||||||
config_file: string;
|
|
||||||
background: string;
|
|
||||||
favicon: string;
|
|
||||||
};
|
|
||||||
image: {
|
|
||||||
size: number;
|
|
||||||
border: number;
|
|
||||||
};
|
|
||||||
fonts: { file: string; size: number }[];
|
|
||||||
redis: {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
db: number;
|
|
||||||
protocol: number;
|
|
||||||
};
|
|
||||||
webdav: {
|
|
||||||
url: string;
|
|
||||||
cache_ttl: number;
|
|
||||||
config_file: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SiteConfigModel {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
content: string;
|
|
||||||
footer: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NumStrDict {
|
|
||||||
[key: number]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DoorSaved {
|
|
||||||
day: number;
|
|
||||||
x1: number;
|
|
||||||
y1: number;
|
|
||||||
x2: number;
|
|
||||||
y2: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageData {
|
|
||||||
height: number;
|
|
||||||
width: number;
|
|
||||||
data_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Credentials = [username: string, password: string];
|
|
|
@ -1,52 +0,0 @@
|
||||||
import { DoorSaved } from "../model";
|
|
||||||
import { Rectangle } from "./rectangle";
|
|
||||||
import { Vector2D } from "./vector2d";
|
|
||||||
|
|
||||||
export class Door {
|
|
||||||
public static readonly MIN_DAY = 1;
|
|
||||||
|
|
||||||
private _day = Door.MIN_DAY;
|
|
||||||
public position: Rectangle;
|
|
||||||
|
|
||||||
constructor(position: Rectangle);
|
|
||||||
constructor(position: Rectangle, day: number);
|
|
||||||
constructor(position: Rectangle, day = Door.MIN_DAY) {
|
|
||||||
this.day = day;
|
|
||||||
this.position = position;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get day(): number {
|
|
||||||
return this._day;
|
|
||||||
}
|
|
||||||
|
|
||||||
public set day(day: unknown) {
|
|
||||||
// integer coercion
|
|
||||||
const result = Number(day);
|
|
||||||
|
|
||||||
if (isNaN(result)) {
|
|
||||||
this._day = Door.MIN_DAY;
|
|
||||||
} else {
|
|
||||||
this._day = Math.max(Math.floor(result), Door.MIN_DAY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static load(serialized: DoorSaved): Door {
|
|
||||||
return new Door(
|
|
||||||
new Rectangle(
|
|
||||||
new Vector2D(serialized.x1, serialized.y1),
|
|
||||||
new Vector2D(serialized.x2, serialized.y2),
|
|
||||||
),
|
|
||||||
serialized.day,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public save(): DoorSaved {
|
|
||||||
return {
|
|
||||||
day: this.day,
|
|
||||||
x1: this.position.origin.x,
|
|
||||||
y1: this.position.origin.y,
|
|
||||||
x2: this.position.corner.x,
|
|
||||||
y2: this.position.corner.y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,132 +0,0 @@
|
||||||
import { acceptHMRUpdate, defineStore } from "pinia";
|
|
||||||
import { API } from "./api";
|
|
||||||
import { Loading } from "./helpers";
|
|
||||||
import { Credentials, DoorSaved, ImageData, SiteConfigModel } from "./model";
|
|
||||||
import { Door } from "./rects/door";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Navigator {
|
|
||||||
readonly msMaxTouchPoints: number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
on_initialized: (() => void)[] | null;
|
|
||||||
is_touch_device: boolean;
|
|
||||||
is_admin: boolean;
|
|
||||||
site_config: SiteConfigModel;
|
|
||||||
background_image: Loading<ImageData>;
|
|
||||||
user_doors: Door[];
|
|
||||||
next_door_target: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const advent22Store = defineStore({
|
|
||||||
id: "advent22",
|
|
||||||
|
|
||||||
state: (): State => ({
|
|
||||||
on_initialized: [],
|
|
||||||
is_touch_device:
|
|
||||||
window.matchMedia("(any-hover: none)").matches ||
|
|
||||||
"ontouchstart" in window ||
|
|
||||||
navigator.maxTouchPoints > 0 ||
|
|
||||||
navigator.msMaxTouchPoints > 0,
|
|
||||||
is_admin: false,
|
|
||||||
site_config: {
|
|
||||||
title: document.title,
|
|
||||||
subtitle: "",
|
|
||||||
content: "",
|
|
||||||
footer: "",
|
|
||||||
},
|
|
||||||
background_image: "loading",
|
|
||||||
user_doors: [],
|
|
||||||
next_door_target: null,
|
|
||||||
}),
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
async init(): Promise<void> {
|
|
||||||
await this.update();
|
|
||||||
|
|
||||||
if (this.on_initialized !== null) {
|
|
||||||
for (const callback of this.on_initialized) callback();
|
|
||||||
}
|
|
||||||
this.on_initialized = null;
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const favicon = await API.request<ImageData>("user/favicon");
|
|
||||||
|
|
||||||
const link: HTMLLinkElement =
|
|
||||||
document.querySelector("link[rel*='icon']") ||
|
|
||||||
document.createElement("link");
|
|
||||||
link.rel = "shortcut icon";
|
|
||||||
link.type = "image/x-icon";
|
|
||||||
link.href = favicon.data_url;
|
|
||||||
|
|
||||||
if (link.parentElement === null)
|
|
||||||
document.getElementsByTagName("head")[0].appendChild(link);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [is_admin, site_config, background_image, user_doors, next_door] =
|
|
||||||
await Promise.all([
|
|
||||||
this.update_is_admin(),
|
|
||||||
API.request<SiteConfigModel>("user/site_config"),
|
|
||||||
API.request<ImageData>("user/background_image"),
|
|
||||||
API.request<DoorSaved[]>("user/doors"),
|
|
||||||
API.request<number | null>("user/next_door"),
|
|
||||||
]);
|
|
||||||
is_admin; // discard value
|
|
||||||
|
|
||||||
document.title = site_config.title;
|
|
||||||
|
|
||||||
if (site_config.subtitle !== "")
|
|
||||||
document.title += " – " + site_config.subtitle;
|
|
||||||
|
|
||||||
this.site_config = site_config;
|
|
||||||
this.background_image = background_image;
|
|
||||||
|
|
||||||
this.user_doors.length = 0;
|
|
||||||
for (const door_saved of user_doors) {
|
|
||||||
this.user_doors.push(Door.load(door_saved));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next_door !== null) this.next_door_target = Date.now() + next_door;
|
|
||||||
} catch {
|
|
||||||
this.background_image = "error";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
when_initialized(callback: () => void): void {
|
|
||||||
if (this.on_initialized === null) {
|
|
||||||
callback();
|
|
||||||
} else {
|
|
||||||
this.on_initialized.push(callback);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async update_is_admin(): Promise<boolean> {
|
|
||||||
this.is_admin = await API.request<boolean>("admin/is_admin");
|
|
||||||
return this.is_admin;
|
|
||||||
},
|
|
||||||
|
|
||||||
login(creds: Credentials): Promise<boolean> {
|
|
||||||
API.creds = { username: creds[0], password: creds[1] };
|
|
||||||
return this.update_is_admin();
|
|
||||||
},
|
|
||||||
|
|
||||||
logout(): Promise<boolean> {
|
|
||||||
return this.login(["", ""]);
|
|
||||||
},
|
|
||||||
|
|
||||||
toggle_touch_device(): void {
|
|
||||||
this.is_touch_device = !this.is_touch_device;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (import.meta.webpackHot) {
|
|
||||||
import.meta.webpackHot.accept(
|
|
||||||
acceptHMRUpdate(advent22Store, import.meta.webpackHot),
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
@charset "utf-8";
|
|
||||||
@use "sass:map";
|
|
||||||
|
|
||||||
//==============
|
|
||||||
// bulma
|
|
||||||
//==============
|
|
||||||
|
|
||||||
// custom color scheme
|
|
||||||
@use "bulma-scheme" as scheme;
|
|
||||||
@use "bulma/sass" with (
|
|
||||||
$primary: map.get(scheme.$colors, "primary"),
|
|
||||||
$link: map.get(scheme.$colors, "link"),
|
|
||||||
$info: map.get(scheme.$colors, "info"),
|
|
||||||
$success: map.get(scheme.$colors, "success"),
|
|
||||||
$warning: map.get(scheme.$colors, "warning"),
|
|
||||||
$danger: map.get(scheme.$colors, "danger")
|
|
||||||
);
|
|
||||||
|
|
||||||
//==============
|
|
||||||
// main imports
|
|
||||||
//==============
|
|
||||||
|
|
||||||
@import "animate.css/animate";
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { advent22Store } from "@/lib/store";
|
|
||||||
import { FontAwesomePlugin } from "@/plugins/fontawesome";
|
|
||||||
import * as bulmaToast from "bulma-toast";
|
|
||||||
import { createPinia } from "pinia";
|
|
||||||
import { createApp } from "vue";
|
|
||||||
import App from "./App.vue";
|
|
||||||
|
|
||||||
import "@/main.scss";
|
|
||||||
|
|
||||||
const app = createApp(App);
|
|
||||||
|
|
||||||
app.use(FontAwesomePlugin);
|
|
||||||
app.use(createPinia());
|
|
||||||
|
|
||||||
advent22Store().init();
|
|
||||||
|
|
||||||
app.mount("#app");
|
|
||||||
|
|
||||||
bulmaToast.setDefaults({
|
|
||||||
duration: 10e3,
|
|
||||||
pauseOnHover: true,
|
|
||||||
dismissible: true,
|
|
||||||
closeOnClick: false,
|
|
||||||
type: "is-white",
|
|
||||||
position: "top-center",
|
|
||||||
animate: { in: "backInDown", out: "backOutUp" },
|
|
||||||
});
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { App, Plugin } from "vue";
|
|
||||||
|
|
||||||
/* import the fontawesome core */
|
|
||||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
|
||||||
|
|
||||||
/* import specific icons */
|
|
||||||
// import { fab } from "@fortawesome/free-brands-svg-icons";
|
|
||||||
import { fas } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
/* add icons to the library */
|
|
||||||
library.add(fas);
|
|
||||||
|
|
||||||
/* import font awesome icon component */
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
|
||||||
|
|
||||||
export const FontAwesomePlugin: Plugin = {
|
|
||||||
install(app: App) {
|
|
||||||
app.component("font-awesome-icon", FontAwesomeIcon);
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -1,90 +0,0 @@
|
||||||
import { expect } from "chai";
|
|
||||||
|
|
||||||
import { Rectangle } from "@/lib/rects/rectangle";
|
|
||||||
import { Vector2D } from "@/lib/rects/vector2d";
|
|
||||||
|
|
||||||
describe("Rectangle Tests", () => {
|
|
||||||
const v1 = new Vector2D(1, 2);
|
|
||||||
const v2 = new Vector2D(4, 6);
|
|
||||||
|
|
||||||
const r1 = new Rectangle(v1, v2);
|
|
||||||
const r2 = new Rectangle(v2, v1);
|
|
||||||
|
|
||||||
function check_rectangle(
|
|
||||||
r: Rectangle,
|
|
||||||
left: number,
|
|
||||||
top: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
) {
|
|
||||||
expect(r.left).to.equal(left);
|
|
||||||
expect(r.top).to.equal(top);
|
|
||||||
|
|
||||||
expect(r.width).to.equal(width);
|
|
||||||
expect(r.height).to.equal(height);
|
|
||||||
expect(r.area).to.equal(width * height);
|
|
||||||
|
|
||||||
expect(r.middle.x).to.equal(left + 0.5 * width);
|
|
||||||
expect(r.middle.y).to.equal(top + 0.5 * height);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("should create a default rectangle", () => {
|
|
||||||
check_rectangle(new Rectangle(), 0, 0, 0, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a rectangle", () => {
|
|
||||||
check_rectangle(r1, 1, 2, 3, 4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create the same rectangle backwards", () => {
|
|
||||||
check_rectangle(r2, 1, 2, 3, 4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should compare rectangles", () => {
|
|
||||||
expect(r1.equals(r2)).to.be.true;
|
|
||||||
expect(r1.equals(new Rectangle())).to.be.false;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create the same rectangle transposed", () => {
|
|
||||||
const v1t = new Vector2D(v1.x, v2.y);
|
|
||||||
const v2t = new Vector2D(v2.x, v1.y);
|
|
||||||
|
|
||||||
expect(r1.equals(new Rectangle(v1t, v2t))).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should contain itself", () => {
|
|
||||||
expect(r1.contains(v1)).to.be.true;
|
|
||||||
expect(r1.contains(v2)).to.be.true;
|
|
||||||
|
|
||||||
expect(r1.contains(r1.origin)).to.be.true;
|
|
||||||
expect(r1.contains(r1.corner)).to.be.true;
|
|
||||||
expect(r1.contains(r1.middle)).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not contain certain points", () => {
|
|
||||||
expect(r1.contains(new Vector2D(0, 0))).to.be.false;
|
|
||||||
expect(r1.contains(new Vector2D(100, 100))).to.be.false;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update a rectangle", () => {
|
|
||||||
const v = new Vector2D(1, 1);
|
|
||||||
|
|
||||||
check_rectangle(r1.update(v1.plus(v), undefined), 2, 3, 2, 3);
|
|
||||||
check_rectangle(r1.update(v1.minus(v), undefined), 0, 1, 4, 5);
|
|
||||||
|
|
||||||
check_rectangle(r1.update(undefined, v2.plus(v)), 1, 2, 4, 5);
|
|
||||||
check_rectangle(r1.update(undefined, v2.minus(v)), 1, 2, 2, 3);
|
|
||||||
|
|
||||||
check_rectangle(r1.update(v1.plus(v), v2.plus(v)), 2, 3, 3, 4);
|
|
||||||
check_rectangle(r1.update(v1.minus(v), v2.minus(v)), 0, 1, 3, 4);
|
|
||||||
|
|
||||||
check_rectangle(r1.update(v1.minus(v), v2.plus(v)), 0, 1, 5, 6);
|
|
||||||
check_rectangle(r1.update(v1.plus(v), v2.minus(v)), 2, 3, 1, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should move a rectangle", () => {
|
|
||||||
const v = new Vector2D(1, 1);
|
|
||||||
|
|
||||||
check_rectangle(r1.move(v), 2, 3, 3, 4);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,41 +0,0 @@
|
||||||
import { expect } from "chai";
|
|
||||||
|
|
||||||
import { Vector2D } from "@/lib/rects/vector2d";
|
|
||||||
|
|
||||||
describe("Vector2D Tests", () => {
|
|
||||||
const v = new Vector2D(1, 2);
|
|
||||||
|
|
||||||
it("should create a default vector", () => {
|
|
||||||
const v0 = new Vector2D();
|
|
||||||
expect(v0.x).to.equal(0);
|
|
||||||
expect(v0.y).to.equal(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a vector", () => {
|
|
||||||
expect(v.x).to.equal(1);
|
|
||||||
expect(v.y).to.equal(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add vectors", () => {
|
|
||||||
const v2 = v.plus(new Vector2D(3, 4));
|
|
||||||
expect(v2.x).to.equal(4);
|
|
||||||
expect(v2.y).to.equal(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should subtract vectors", () => {
|
|
||||||
const v2 = v.minus(new Vector2D(3, 4));
|
|
||||||
expect(v2.x).to.equal(-2);
|
|
||||||
expect(v2.y).to.equal(-2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should scale vectors", () => {
|
|
||||||
const v2 = v.scale(3);
|
|
||||||
expect(v2.x).to.equal(3);
|
|
||||||
expect(v2.y).to.equal(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should compare vectors", () => {
|
|
||||||
expect(v.equals(v.scale(1))).to.be.true;
|
|
||||||
expect(v.equals(v.scale(2))).to.be.false;
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
7533
ui/yarn.lock
7533
ui/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue