Compare commits

..

No commits in common. "master" and "0.1.0" have entirely different histories.

75 changed files with 10041 additions and 4357 deletions

View file

@ -1,14 +0,0 @@
name: advent22
services:
api:
image: mcr.microsoft.com/devcontainers/python:3-3.14-trixie
volumes:
- ../:/workspaces/advent22:cached
command: sleep infinity
ui:
image: mcr.microsoft.com/devcontainers/javascript-node:4-24-trixie
volumes:
- ../:/workspaces/advent22:cached
command: sleep infinity

View file

@ -11,12 +11,9 @@
**/.dockerignore
# found in python and JS dirs
**/__pycache__/
**/node_modules/
**/.pytest_cache/
**/.ruff_cache/
**/.uv_cache/
**/.venv/
**/__pycache__
**/node_modules
**/.pytest_cache
# env files
**/.env
@ -28,6 +25,3 @@
**/yarn-debug.log*
**/yarn-error.log*
**/pnpm-debug.log*
# custom files
api/api.conf

View file

@ -1,6 +1,51 @@
ARG NODE_VERSION=24
ARG PYTHON_VERSION=3.14
ARG PYTHON_VERSION=3.14-slim
#############
# build api #
#############
ARG PYTHON_VERSION
FROM python:${PYTHON_VERSION} AS build-api
# env setup
WORKDIR /usr/local/src/advent22_api
ENV \
PATH="/root/.local/bin:${PATH}"
# install poetry with export plugin
RUN set -ex; \
\
python -m pip --no-cache-dir install --upgrade pip wheel; \
\
apt-get update; apt-get install --yes --no-install-recommends \
curl \
; rm -rf /var/lib/apt/lists/*; \
\
curl -sSL https://install.python-poetry.org | python3 -; \
poetry self add poetry-plugin-export;
# build dependency wheels
COPY api/pyproject.toml api/poetry.lock ./
RUN set -ex; \
\
# # buildtime dependencies
# apt-get update; apt-get install --yes --no-install-recommends \
# build-essential \
# ; rm -rf /var/lib/apt/lists/*; \
\
# generate requirements.txt
poetry export \
--format requirements.txt \
--output requirements.txt; \
\
python3 -m pip --no-cache-dir wheel \
--wheel-dir ./dist \
--requirement requirements.txt;
# build advent22_api wheel
COPY api ./
RUN poetry build --format wheel --output ./dist
############
# build ui #
@ -9,90 +54,76 @@ ARG PYTHON_VERSION=3.14
ARG NODE_VERSION
FROM node:${NODE_VERSION} AS build-ui
# install ui dependencies
# env setup
WORKDIR /usr/local/src/advent22_ui
RUN --mount=type=bind,source=ui/package.json,target=package.json \
--mount=type=bind,source=ui/yarn.lock,target=yarn.lock \
--mount=type=bind,source=ui/.yarn/releases,target=.yarn/releases \
--mount=type=bind,source=ui/.yarnrc.yml,target=.yarnrc.yml \
--mount=type=cache,id=ui,target=/root/.yarn \
\
yarn install --immutable --check-cache;
# copy and build ui
# install advent22_ui dependencies
COPY ui/package*.json ui/yarn*.lock ./
RUN set -ex; \
corepack enable; \
yarn install;
# copy and build advent22_ui
COPY ui ./
RUN --mount=type=cache,id=ui,target=/root/.yarn \
set -ex; \
\
yarn build --outDir /opt/advent22/ui; \
# exclude vite-bundle-analyzer output
rm /opt/advent22/ui/stats.html;
RUN set -ex; \
yarn dlx update-browserslist-db@latest; \
yarn build --dest /tmp/advent22_ui/html; \
# exclude webpack-bundle-analyzer output
rm -f /tmp/advent22_ui/html/report.html;
###############
# install app #
###############
######################
# python preparation #
######################
ARG PYTHON_VERSION
FROM dhi.io/python:${PYTHON_VERSION}-dev AS install-app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
FROM python:${PYTHON_VERSION} AS uvicorn-gunicorn
# where credit is due ...
LABEL maintainer="Sebastián Ramirez <tiangolo@gmail.com>"
WORKDIR /usr/local/share/uvicorn-gunicorn
# install uvicorn-gunicorn
COPY ./scripts/mini-tiangolo ./
RUN set -ex; \
chmod +x start.sh; \
python3 -m pip --no-cache-dir install gunicorn;
CMD ["/usr/local/share/uvicorn-gunicorn/start.sh"]
###########
# web app #
###########
FROM uvicorn-gunicorn AS production
# env setup
WORKDIR /opt/advent22
ENV UV_COMPILE_BYTECODE=1 \
UV_NO_DEV=1 \
UV_LINK_MODE="copy"
ENV \
PRODUCTION_MODE="true" \
PORT="8000" \
MODULE_NAME="advent22_api.app"
EXPOSE 8000
RUN --mount=type=bind,source=api/uv.lock,target=api/uv.lock \
--mount=type=bind,source=api/pyproject.toml,target=api/pyproject.toml \
--mount=type=bind,source=api/.python-version,target=api/.python-version \
--mount=type=cache,id=api,target=/root/.cache/uv \
set -ex; \
WORKDIR /opt/advent22
VOLUME [ "/opt/advent22" ]
COPY --from=build-api /usr/local/src/advent22_api/dist /usr/local/share/advent22_api.dist
RUN set -ex; \
# remove example app
rm -rf /app; \
\
# # runtime dependencies
# apt-get update; apt-get install --yes --no-install-recommends \
# ; rm -rf /var/lib/apt/lists/*; \
\
# install advent22_api wheels
python3 -m pip --no-cache-dir install --no-deps /usr/local/share/advent22_api.dist/*.whl; \
\
# prepare data directory
mkdir data; \
chown nobody:nobody data; \
chmod u=rwx,g=rx,o=rx data; \
\
# install api deps
uv sync \
--project api/ \
--locked \
--no-install-project \
--no-editable \
;
chown nobody:nogroup ./
# install api
COPY api api/
RUN --mount=type=cache,id=api,target=/root/.cache/uv \
\
uv sync \
--project api/ \
--locked \
--no-editable \
;
# add prepared ui
COPY --from=build-ui /opt/advent22/ui ui/
####################
# production image #
####################
ARG PYTHON_VERSION
FROM dhi.io/python:${PYTHON_VERSION} AS production
ENV PATH="/opt/advent22/api/.venv/bin:$PATH"
EXPOSE 8000
CMD [ "advent22" ]
ARG PYTHON_VERSION
COPY --from=install-app /opt/python/lib/python${PYTHON_VERSION} /opt/python/lib/python${PYTHON_VERSION}/
COPY --from=install-app /opt/advent22 /opt/advent22/
WORKDIR /opt/advent22/data
VOLUME [ "/opt/advent22/data" ]
# add prepared advent22_ui
COPY --from=build-ui /tmp/advent22_ui /usr/local/share/advent22_ui
# run as unprivileged user
USER nobody

View file

@ -4,18 +4,12 @@
"name": "Advent22 API",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"dockerComposeFile": "../../.devcontainer/docker_compose.yml",
"service": "api",
"workspaceFolder": "/workspaces/advent22/api",
"runServices": ["api"],
"image": "mcr.microsoft.com/devcontainers/python:3-3.14-trixie",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/git-lfs:1": {},
"ghcr.io/devcontainers-extra/features/uv:1": {},
"ghcr.io/devcontainers-extra/features/zsh-plugins:0": {
"plugins": "git-flow uv"
},
"ghcr.io/devcontainers-extra/features/poetry:2": {},
"ghcr.io/devcontainers-extra/features/apt-get-packages:1": {
"packages": "git-flow"
},
@ -23,8 +17,7 @@
},
"containerEnv": {
"TZ": "Europe/Berlin",
"UV_CACHE_DIR": "/workspaces/advent22/.uv_cache"
"TZ": "Europe/Berlin"
},
// Configure tool-specific properties.
@ -38,21 +31,22 @@
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"astral-sh.ty",
"charliermarsh.ruff",
"be5invis.toml",
"mhutchie.git-graph",
"ms-python.python",
"ms-python.black-formatter",
"ms-python.flake8",
"ms-python.isort",
"ms-python.vscode-pylance"
]
}
},
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "uv tool install uv-upx",
"postCreateCommand": "sudo /usr/local/py-utils/bin/poetry self add poetry-plugin-up",
// Use 'postStartCommand' to run commands after the container is started.
"postStartCommand": "uv tool upgrade uv-upx && uv sync"
"postStartCommand": "poetry install"
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],

4
api/.flake8 Normal file
View file

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

3
api/.isort.cfg Normal file
View file

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

View file

@ -1 +0,0 @@
3.14

View file

@ -5,25 +5,18 @@
"version": "0.2.0",
"configurations": [
{
"name": "FastAPI CLI (dev)",
"type": "debugpy",
"name": "Main Module",
"type": "python",
"request": "launch",
"module": "fastapi",
"args": [
"dev",
"--host",
"0.0.0.0",
"--port",
"8000",
"--entrypoint",
"advent22_api.app:app",
"--reload-dir",
"${workspaceFolder}/advent22_api"
"module": "advent22_api.main",
"pythonArgs": [
"-Xfrozen_modules=off",
],
"env": {
"ADVENT22__WEBDAV__CACHE_TTL": "30"
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
"WEBDAV__CACHE_TTL": "30",
},
"justMyCode": true
"justMyCode": true,
}
]
}

View file

@ -3,16 +3,31 @@
"[python]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.fixAll": "explicit"
}
"source.fixAll": "explicit",
},
},
"python.languageServer": "Pylance",
"python.analysis.autoImportCompletions": true,
"python.analysis.importFormat": "relative",
"python.analysis.fixAll": [
"source.convertImportFormat",
"source.unusedImports",
],
"python.analysis.typeCheckingMode": "basic",
"python.analysis.diagnosticMode": "workspace",
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": [
"--import-mode=importlib",
"test",
],
"ty.diagnosticMode": "workspace",
"ruff.nativeServer": "on"
"black-formatter.importStrategy": "fromEnvironment",
"flake8.importStrategy": "fromEnvironment",
"isort.importStrategy": "fromEnvironment",
}

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 Lenaisten e.V.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

View file

@ -1,4 +1,5 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from .core.settings import SETTINGS
@ -32,3 +33,14 @@ if SETTINGS.production_mode:
),
name="frontend",
)
else:
# Allow CORS in debug mode
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)

View file

@ -1,7 +1,7 @@
import colorsys
import logging
from dataclasses import dataclass
from typing import Self, cast
from typing import AnyStr, Self, TypeAlias
import numpy as np
from PIL import Image as PILImage
@ -11,9 +11,9 @@ from PIL.ImageFont import FreeTypeFont
from .config import Config
type _RGB = tuple[int, int, int]
type _XY = tuple[float, float]
type _Box = tuple[int, int, int, int]
_RGB: TypeAlias = tuple[int, int, int]
_XY: TypeAlias = tuple[float, float]
_Box: TypeAlias = tuple[int, int, int, int]
_logger = logging.getLogger(__name__)
@ -56,7 +56,7 @@ class AdventImage:
async def get_text_box(
self,
xy: _XY,
text: str,
text: AnyStr,
font: FreeTypeFont,
anchor: str | None = "mm",
**text_kwargs,
@ -95,12 +95,12 @@ class AdventImage:
pixel_data = np.asarray(self.img.crop(box))
mean_color: np.ndarray = np.mean(pixel_data, axis=(0, 1))
return cast(_RGB, mean_color.astype(int))
return _RGB(mean_color.astype(int))
async def hide_text(
self,
xy: _XY,
text: str,
text: AnyStr,
font: FreeTypeFont,
anchor: str | None = "mm",
**text_kwargs,
@ -134,10 +134,8 @@ class AdventImage:
else:
tc_v -= 3
text_color: tuple[int | float, int | float, int | float] = colorsys.hsv_to_rgb(
tc_h, tc_s, tc_v
)
text_color = cast(_RGB, tuple(int(val) for val in text_color))
text_color = colorsys.hsv_to_rgb(tc_h, tc_s, tc_v)
text_color = _RGB(int(val) for val in text_color)
# Buchstaben verstecken
ImageDraw.Draw(self.img).text(

View file

@ -1,4 +1,5 @@
import tomllib
from typing import TypeAlias
import tomli_w
from fastapi import Depends
@ -19,7 +20,7 @@ class DoorSaved(BaseModel):
y2: int
type DoorsSaved = list[DoorSaved]
DoorsSaved: TypeAlias = list[DoorSaved]
class CalendarConfig(BaseModel):

View file

@ -55,6 +55,6 @@ class RedisCache(__RedisCache):
try:
return super()._deserialize(s)
except UnicodeDecodeError, JSONDecodeError:
except (UnicodeDecodeError, JSONDecodeError):
assert isinstance(s, bytes)
return s

View file

@ -219,7 +219,7 @@ async def get_day_image(
image = await AdventImage.from_img(img, cfg)
return image.img
except KeyError, RuntimeError:
except (KeyError, RuntimeError):
# Erstelle automatisch generiertes Bild
return await gen_day_auto_image(
day=day,

View file

@ -1,6 +1,10 @@
from pydantic import BaseModel, Field
from typing import TypeVar
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
T = TypeVar("T")
class Credentials(BaseModel):
username: str = ""
@ -51,7 +55,6 @@ class Settings(BaseSettings):
"""
model_config = SettingsConfigDict(
env_prefix="ADVENT22__",
env_file="api.conf",
env_file_encoding="utf-8",
env_nested_delimiter="__",
@ -62,32 +65,29 @@ class Settings(BaseSettings):
#####
production_mode: bool = False
show_api_docs: bool = Field(
default_factory=lambda data: not data["production_mode"]
)
ui_directory: str = "/opt/advent22/ui"
ui_directory: str = "/usr/local/share/advent22_ui/html"
#####
# openapi settings
#####
def __api_docs[T](self, value: T) -> T | None:
if self.show_api_docs:
return value
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.__api_docs("/api/openapi.json")
return self.__dev_value("/api/openapi.json")
@property
def docs_url(self) -> str | None:
return self.__api_docs("/api/docs")
return self.__dev_value("/api/docs")
@property
def redoc_url(self) -> str | None:
return self.__api_docs("/api/redoc")
return self.__dev_value("/api/redoc")
#####
# webdav settings

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

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

View file

@ -1,49 +0,0 @@
import os
from granian import Granian
from granian.constants import Interfaces, Loops
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class WorkersSettings(BaseModel):
per_core: int = Field(1, ge=1)
max: int | None = Field(None, ge=1)
exact: int | None = Field(None, ge=1)
@property
def count(self) -> int:
# usage of "or" operator: values here are not allowed to be 0
base = self.exact or (self.per_core * (os.cpu_count() or 1))
return min(base, self.max or base)
class BindSettings(BaseModel):
host: str = "0.0.0.0"
port: int = 8000
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="ADVENT22__",
env_nested_delimiter="__",
)
workers: WorkersSettings = WorkersSettings()
bind: BindSettings = BindSettings()
def start():
os.environ["ADVENT22__PRODUCTION_MODE"] = "true"
settings = Settings()
server = Granian(
"advent22_api.app:app",
address=settings.bind.host,
port=settings.bind.port,
workers=settings.workers.count,
interface=Interfaces.ASGI,
loop=Loops.uvloop,
process_name="advent22",
)
server.serve()

1876
api/poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,44 +1,33 @@
[project]
name = "advent22-api"
version = "0.2.0"
description = ""
license = {file = "LICENSE"}
readme = "README.md"
requires-python = ">=3.14"
[tool.poetry]
authors = [
{name = "Jörn-Michael Miehe", email = "jmm@yavook.de"},
{name = "Penner42", email = "unbekannt42@web.de"},
]
dependencies = [
"asyncify>=0.12.1",
"cachetools>=7.0.1",
"cachetoolsutils>=11.0",
"fastapi>=0.129.2",
"granian[pname,uvloop]>=2.7.1",
"markdown>=3.10.2",
"numpy>=2.4.2",
"pillow>=12.1.1",
"pydantic-settings>=2.13.1",
"redis[hiredis]>=7.2.0",
"requests>=2.32.5",
"tomli-w>=1.2.0",
"webdavclient3>=3.14.7",
"Jörn-Michael Miehe <jmm@yavook.de>",
"Penner42 <unbekannt42@web.de>",
]
description = ""
license = "MIT"
name = "advent22_api"
version = "0.1.0"
[project.scripts]
advent22 = "advent22_api.production:start"
[tool.poetry.dependencies]
Pillow = "^12.1.1"
asyncify = "^0.12.1"
cachetools = "^7.0.1"
cachetoolsutils = "^11.0"
fastapi = "^0.129.0"
markdown = "^3.10.2"
numpy = "^2.4.2"
pydantic-settings = "^2.13.0"
python = ">=3.11,<3.15"
redis = {extras = ["hiredis"], version = "^7.1.1"}
tomli-w = "^1.2.0"
uvicorn = {extras = ["standard"], version = "^0.40.0"}
webdavclient3 = "^3.14.7"
[dependency-groups]
dev = [
"fastapi[standard]>=0.129.2",
"pytest>=9.0.2",
"ruff>=0.15.2",
]
[tool.poetry.group.dev.dependencies]
black = "^26.1.0"
flake8 = "^7.3.0"
pytest = "^9.0.2"
[build-system]
requires = ["uv_build>=0.10.4,<0.11.0", "packaging"]
build-backend = "uv_build"
[tool.uv.build-backend]
# module-name = "advent22_api"
module-root = ""
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core>=1.0.0"]

File diff suppressed because it is too large Load diff

View file

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

View file

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

4
ui/.browserslistrc Normal file
View file

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

View file

@ -4,17 +4,11 @@
"name": "Advent22 UI",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"dockerComposeFile": "../../.devcontainer/docker_compose.yml",
"service": "ui",
"workspaceFolder": "/workspaces/advent22/ui",
"runServices": ["ui"],
"image": "mcr.microsoft.com/devcontainers/javascript-node:4-24-trixie",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/git-lfs:1": {},
"ghcr.io/devcontainers-extra/features/zsh-plugins:0": {
"plugins": "git-flow npm nvm yarn"
},
"ghcr.io/devcontainers-extra/features/apt-get-packages:1": {
"packages": "git-flow"
},
@ -32,12 +26,9 @@
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"mhutchie.git-graph",
"oxc.oxc-vscode",
"Syler.sass-indented",
"vitest.explorer",
"Vue.volar"
]
}
@ -47,7 +38,7 @@
// "postCreateCommand": "yarn install",
// Use 'postStartCommand' to run commands after the container is started.
"postStartCommand": "yarn install"
"postStartCommand": "yarn dlx update-browserslist-db@latest && yarn install"
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],

View file

@ -1,8 +0,0 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

38
ui/.eslintrc.js Normal file
View file

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

1
ui/.gitattributes vendored
View file

@ -1 +0,0 @@
* text=auto eol=lf

15
ui/.gitignore vendored
View file

@ -1,19 +1,4 @@
# from newly scaffolded vite project
.DS_Store
dist-ssr
coverage
*.local
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
# https://raw.githubusercontent.com/github/gitignore/refs/heads/main/Node.gitignore

View file

@ -1,10 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

View file

@ -1,7 +0,0 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"singleQuote": false,
"printWidth": 100,
"trailingComma": "all"
}

View file

@ -1,12 +1,3 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"mhutchie.git-graph",
"oxc.oxc-vscode",
"Syler.sass-indented",
"vitest.explorer",
"Vue.volar"
]
"recommendations": ["sdras.vue-vscode-snippets"]
}

View file

@ -4,11 +4,10 @@
},
"[jsonc]": {
"editor.formatOnSave": false
"editor.formatOnSave": false,
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
@ -16,14 +15,9 @@
"editor.formatOnSave": true,
"editor.tabSize": 2,
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts, typed-router.d.ts",
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .oxfmt*, .prettier*, prettier*, .editorconfig"
},
"sass.disableAutoIndent": true,
"sass.format.convert": false,
"sass.format.deleteWhitespace": true
"sass.format.deleteWhitespace": true,
"prettier.trailingComma": "all",
}

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

@ -3,21 +3,10 @@
"tasks": [
{
"type": "npm",
"script": "dev",
"script": "serve",
"problemMatcher": [],
"label": "UI starten"
},
{
"type": "npm",
"script": "lint",
"problemMatcher": ["$eslint-compact"],
"label": "Linter"
},
{
"type": "npm",
"script": "format",
"problemMatcher": [],
"label": "Formatter"
"label": "UI starten",
"detail": "vue-cli-service serve"
}
]
}

View file

@ -1,54 +1,29 @@
# advent22_ui
This template should help get you started developing with Vue 3 in Vite.
## Project setup
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
yarn
```
yarn install
```
### Compile and Hot-Reload for Development
### Compiles and hot-reloads for development
```sh
yarn dev
```
yarn serve
```
### Type-Check, Compile and Minify for Production
### Compiles and minifies for production
```sh
```
yarn build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
### Lints and fixes files
```sh
yarn test:unit
```
### Lint with [ESLint](https://eslint.org/)
```sh
yarn lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

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

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

1
ui/env.d.ts vendored
View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -1,32 +0,0 @@
import { globalIgnores } from "eslint/config";
import { defineConfigWithVueTs, vueTsConfigs } from "@vue/eslint-config-typescript";
import pluginVue from "eslint-plugin-vue";
import pluginVitest from "@vitest/eslint-plugin";
import pluginOxlint from "eslint-plugin-oxlint";
import skipFormatting from "eslint-config-prettier/flat";
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: "app/files-to-lint",
files: ["**/*.{vue,ts,mts,tsx}"],
},
globalIgnores(["**/dist/**", "**/dist-ssr/**", "**/coverage/**"]),
...pluginVue.configs["flat/essential"],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ["src/**/__tests__/*"],
},
...pluginOxlint.buildFromOxlintConfigFile(".oxlintrc.json"),
skipFormatting,
);

View file

@ -1,63 +1,47 @@
{
"name": "advent22_ui",
"version": "0.2.0",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"dev": "vite --host",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview --host",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s 'lint:*'",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"pinia": "^3.0.4",
"vue": "^3.5.28"
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit",
"test:unit-watch": "vue-cli-service test:unit --watch",
"lint": "vue-cli-service lint",
"ui": "vue ui --host 0.0.0.0 --headless"
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/vue-fontawesome": "^3.1.3",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^27.0.0",
"@types/chai": "^5.2.3",
"@types/luxon": "^3.7.1",
"@types/node": "^25.3.0",
"@vitejs/plugin-vue": "^6.0.4",
"@vitest/eslint-plugin": "^1.6.9",
"@vue/eslint-config-typescript": "^14.7.0",
"@types/mocha": "^10.0.10",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.55.0",
"@vue/cli-plugin-babel": "^5.0.9",
"@vue/cli-plugin-eslint": "^5.0.9",
"@vue/cli-plugin-typescript": "^5.0.9",
"@vue/cli-plugin-unit-mocha": "^5.0.9",
"@vue/cli-service": "^5.0.9",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"animate.css": "^4.1.1",
"axios": "^1.13.5",
"bulma": "^1.0.4",
"bulma-toast": "2.4.3",
"eslint": "^10.0.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.49.0",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
"chai": "^6.2.2",
"core-js": "^3.48.0",
"eslint": "^8.57.1",
"eslint-plugin-vue": "^9.33.0",
"luxon": "^3.7.2",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.49.0",
"prettier": "3.8.1",
"sass-embedded": "^1.97.3",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vite-bundle-analyzer": "^1.3.6",
"vite-plugin-html": "^3.2.2",
"vite-plugin-vue-devtools": "^8.0.6",
"vitest": "^4.0.18",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.5"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
"pinia": "^3.0.4",
"sass": "~1.94.3",
"sass-loader": "^16.0.0",
"typescript": "^5.9.3",
"vue": "^3.5.25",
"vue-cli-plugin-webpack-bundle-analyzer": "^4.0.0"
}
}

View file

@ -1,11 +1,11 @@
<!doctype html>
<!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="/favicon.ico" />
<title><%= title %></title>
<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 || []);
@ -29,11 +29,12 @@
<body>
<noscript>
<strong
>Es tut uns leid, aber <%= title %> funktioniert nicht richtig ohne JavaScript. Bitte
aktivieren Sie es, um fortzufahren.</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>
<script type="module" src="/src/main.ts"></script>
<!-- built files will be auto injected -->
</body>
</html>

View file

@ -7,8 +7,15 @@
</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">
<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">

View file

@ -4,8 +4,8 @@
<BulmaToast @handle="on_toast_handle" class="content">
<p>
Du hast noch keine Türchen geöffnet, vielleicht gibt es ein Anzeigeproblem in Deinem
Webbrowser?
Du hast noch keine Türchen geöffnet, vielleicht gibt es ein Anzeigeproblem
in Deinem Webbrowser?
</p>
<div class="level">
<div class="level-item">

View file

@ -12,7 +12,12 @@
<div class="field">
<label class="label">Username</label>
<div class="control">
<input ref="username_input" class="input" type="text" v-model="creds.username" />
<input
ref="username_input"
class="input"
type="text"
v-model="creds.username"
/>
</div>
</div>

View file

@ -18,7 +18,10 @@
</template>
</div>
<button v-if="state.show !== 'loading'" class="modal-close is-large has-background-primary" />
<button
v-if="state.show !== 'loading'"
class="modal-close is-large has-background-primary"
/>
</div>
</template>

View file

@ -7,9 +7,12 @@
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-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:
{{ store.user_doors.length }} Türchen offen. Zeit bis zum nächsten
Türchen:
</template>
<CountDown :until="store.next_door_target" />
</template>

View file

@ -8,7 +8,7 @@
<h3>Zuordnung Buchstaben</h3>
<div class="tags are-medium">
<template v-for="[day, data] in day_data" :key="`part-${day}`">
<template v-for="(data, day) in day_data" :key="`part-${day}`">
<span v-if="data.part === ''" class="tag is-warning">
{{ day }}
</span>
@ -21,7 +21,7 @@
<h3>Zuordnung Bilder</h3>
<div class="tags are-medium">
<span
v-for="[day, data] in day_data"
v-for="(data, day) in day_data"
:key="`image-${day}`"
:class="'tag is-' + (data.part === '' ? 'warning' : 'primary')"
>
@ -32,7 +32,7 @@
<h3>Alle Türchen</h3>
<div class="tags are-medium">
<BulmaButton
v-for="[day, data] in day_data"
v-for="(data, day) in day_data"
:key="`btn-${day}`"
:class="'tag is-' + (data.part === '' ? 'warning' : 'info')"
:icon="['fas', 'fa-door-open']"
@ -48,19 +48,14 @@
<script setup lang="ts">
import { API } from "@/lib/api";
import { name_door, objForEach } from "@/lib/helpers";
import type { ImageData } from "@/lib/model";
import type { ImageData, NumStrDict } from "@/lib/model";
import { ref } from "vue";
import MultiModal, { type HMultiModal } from "../MultiModal.vue";
import BulmaButton from "../bulma/Button.vue";
import BulmaDrawer from "../bulma/Drawer.vue";
interface DayData {
part: string;
image_name: string;
}
const day_data = ref<Map<number, DayData>>(new Map());
const day_data = ref<Record<number, { part: string; image_name: string }>>({});
let modal: HMultiModal | undefined;
@ -70,31 +65,24 @@ function on_modal_handle(handle: HMultiModal): void {
async function on_open(): Promise<void> {
const [day_parts, day_image_names] = await Promise.all([
API.request<Record<number, string>>("admin/day_parts"),
API.request<Record<number, string>>("admin/day_image_names"),
API.request<NumStrDict>("admin/day_parts"),
API.request<NumStrDict>("admin/day_image_names"),
]);
const _get_data = (day: number) => {
let result = day_data.value.get(day);
if (result === undefined) {
result = { part: "", image_name: "" };
day_data.value.set(day, result);
const _ensure_day_in_data = (day: number) => {
if (!(day in day_data.value)) {
day_data.value[day] = { part: "", image_name: "" };
}
return result;
};
// for (const [day, part] of day_parts.entries()) {
// _get_data(day).part = part;
// }
objForEach(day_parts, (day, part) => {
_get_data(day).part = part;
_ensure_day_in_data(day);
day_data.value[day].part = part;
});
objForEach(day_image_names, (day, image_name) => {
_get_data(day).image_name = image_name;
_ensure_day_in_data(day);
day_data.value[day].image_name = image_name;
});
}

View file

@ -9,11 +9,15 @@
<dt>Wert</dt>
<dd>
Eingabe:
<span class="is-family-monospace"> "{{ admin_config_model.solution.value }}" </span>
<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>
<span class="is-family-monospace">
"{{ admin_config_model.solution.clean }}"
</span>
</dd>
<dt>Transformation</dt>
@ -43,7 +47,9 @@
<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-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>
@ -59,7 +65,9 @@
<dd>{{ fmt_puzzle_date("end") }}</dd>
<dt>Zufalls-Seed</dt>
<dd class="is-family-monospace">"{{ admin_config_model.puzzle.seed }}"</dd>
<dd class="is-family-monospace">
"{{ admin_config_model.puzzle.seed }}"
</dd>
<dt>Extra-Tage</dt>
<dd>
@ -113,7 +121,10 @@
<dd>{{ admin_config_model.image.border }} px</dd>
<dt>Schriftarten</dt>
<dd v-for="(font, index) in admin_config_model.fonts" :key="`font-${index}`">
<dd
v-for="(font, index) in admin_config_model.fonts"
:key="`font-${index}`"
>
{{ font.file }} ({{ font.size }} pt)
</dd>
</dl>
@ -232,7 +243,7 @@ const admin_config_model = ref<AdminConfigModel>({
});
const doors = ref<DoorSaved[]>([]);
const creds = ref({
const creds = ref<Record<string, Credentials>>({
dav: {
username: "",
password: "",
@ -265,7 +276,10 @@ async function on_open(): Promise<void> {
clear_credentials(creds.value.ui);
}
async function load_credentials(creds: Credentials, endpoint: string): Promise<void> {
async function load_credentials(
creds: Credentials,
endpoint: string,
): Promise<void> {
try {
const new_creds = await API.request<Credentials>(endpoint);

View file

@ -8,7 +8,11 @@
:icon="['fas', 'fa-backward']"
/>
<BulmaBreadcrumbs :steps="steps" v-model="current_step" class="level-item mb-0" />
<BulmaBreadcrumbs
:steps="steps"
v-model="current_step"
class="level-item mb-0"
/>
<BulmaButton
:disabled="current_step === 2"

View file

@ -3,7 +3,11 @@
<button class="button">
<slot name="default">
<span v-if="icon !== undefined" class="icon">
<FontAwesomeIcon v-if="icon !== undefined" :icon="icon" :beat-fade="busy" />
<FontAwesomeIcon
v-if="icon !== undefined"
:icon="icon"
:beat-fade="busy"
/>
</span>
</slot>
<span v-if="text !== undefined">{{ text }}</span>

View file

@ -6,13 +6,18 @@
<p v-if="refreshable && is_open" class="card-header-icon px-0">
<BulmaButton class="is-small is-primary" @click="load">
<FontAwesomeIcon :icon="['fas', 'arrows-rotate']" :spin="state === 'loading'" />
<FontAwesomeIcon
:icon="['fas', 'arrows-rotate']"
:spin="state === 'loading'"
/>
</BulmaButton>
</p>
<button class="card-header-icon" @click="toggle">
<span class="icon">
<FontAwesomeIcon :icon="['fas', is_open ? 'angle-down' : 'angle-right']" />
<FontAwesomeIcon
:icon="['fas', is_open ? 'angle-down' : 'angle-right']"
/>
</span>
</button>
</header>

View file

@ -6,11 +6,7 @@
>
<div
class="has-text-danger"
style="
text-shadow:
0 0 10px white,
0 0 20px white;
"
style="text-shadow: 0 0 10px white, 0 0 20px white"
>
{{ door.day }}
</div>

View file

@ -27,7 +27,13 @@ import { computed } from "vue";
const store = advent22Store();
type BulmaVariant = "primary" | "link" | "info" | "success" | "warning" | "danger";
type BulmaVariant =
| "primary"
| "link"
| "info"
| "success"
| "warning"
| "danger";
withDefaults(
defineProps<{

View file

@ -32,7 +32,11 @@ function get_event_thous(event: MouseEvent): Vector2D {
type TCEventType = "mousedown" | "mousemove" | "mouseup" | "click" | "dblclick";
const is_tceventtype = (t: unknown): t is TCEventType =>
t === "mousedown" || t === "mousemove" || t === "mouseup" || t === "click" || t === "dblclick";
t === "mousedown" ||
t === "mousemove" ||
t === "mouseup" ||
t === "click" ||
t === "dblclick";
const emit = defineEmits<{
(event: TCEventType, e: MouseEvent, point: Vector2D): void;

View file

@ -14,7 +14,12 @@
:door="door"
force_visible
/>
<SVGRect v-if="preview_visible" variant="success" :rectangle="preview" visible />
<SVGRect
v-if="preview_visible"
variant="success"
:rectangle="preview"
visible
/>
</ThouCanvas>
</template>

View file

@ -14,10 +14,9 @@
<img :src="unwrap_loading(store.background_image).data_url" />
<ThouCanvas>
<PreviewDoor
v-for="(door, index) in model"
v-for="(_, index) in model"
:key="`door-${index}`"
:model-value="door"
@update:model-value="updateAt(index, door)"
v-model="model[index]"
/>
</ThouCanvas>
</figure>
@ -25,7 +24,7 @@
</template>
<script setup lang="ts">
import { unwrap_loading, type VueLike } from "@/lib/helpers";
import { type VueLike, unwrap_loading } from "@/lib/helpers";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store";
@ -33,12 +32,5 @@ import ThouCanvas from "../calendar/ThouCanvas.vue";
import PreviewDoor from "./PreviewDoor.vue";
const model = defineModel<VueLike<Door>[]>({ required: true });
function updateAt(i: number, val: VueLike<Door>) {
const copy = [...model.value];
copy[i] = val;
model.value = copy;
}
const store = advent22Store();
</script>

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

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

View file

@ -16,9 +16,23 @@ interface Params {
}
export class API {
private static get api_baseurl(): string {
// in production mode, return "proto://hostname/api"
if (process.env.NODE_ENV === "production") {
return `${window.location.protocol}//${window.location.host}/api`;
} else if (process.env.NODE_ENV !== "development") {
// not in prouction or development mode
// eslint-disable-next-line no-console
console.warn("Unexpected NODE_ENV value: ", process.env.NODE_ENV);
}
// in development mode, return "proto://hostname:8000/api"
return `${window.location.protocol}//${window.location.hostname}:8000/api`;
}
private static readonly axios = axios.create({
timeout: 15e3,
baseURL: "/api",
timeout: 10e3,
baseURL: this.api_baseurl,
});
private static readonly creds_key = "advent22/credentials";
@ -70,6 +84,7 @@ export class API {
const response = await this.axios.request<T>(this.get_axios_config(p));
return response.data;
} catch (reason) {
// eslint-disable-next-line no-console
console.error(`Failed to query ${p.endpoint}: ${reason}`);
throw new APIError(reason, p.endpoint);
}

View file

@ -1,7 +1,10 @@
import { nextTick, type UnwrapRef } from "vue";
import { APIError } from "./api_error";
export function objForEach<T>(obj: T, f: (k: keyof T, v: T[keyof T]) => void): void {
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]);
@ -39,6 +42,7 @@ export function handle_error(error: unknown): void {
if (error instanceof APIError) {
error.alert();
} else {
// eslint-disable-next-line no-console
console.error(error);
}
}

View file

@ -45,6 +45,10 @@ export interface SiteConfigModel {
footer: string;
}
export interface NumStrDict {
[key: number]: string;
}
export interface DoorSaved {
day: number;
x1: number;

View file

@ -24,7 +24,10 @@ export class Door {
// integer coercion
let day = Number(value);
day = !Number.isNaN(day) && Number.isFinite(day) ? Math.trunc(day) : Door.MIN_DAY;
day =
!Number.isNaN(day) && Number.isFinite(day)
? Math.trunc(day)
: Door.MIN_DAY;
this._day = Math.max(day, Door.MIN_DAY);
}

View file

@ -71,6 +71,9 @@ export class Rectangle {
}
public move(vector: Vector2D): Rectangle {
return new Rectangle(this.corner_1.plus(vector), this.corner_2.plus(vector));
return new Rectangle(
this.corner_1.plus(vector),
this.corner_2.plus(vector),
);
}
}

View file

@ -1,4 +1,4 @@
import { defineStore } from "pinia";
import { acceptHMRUpdate, defineStore } from "pinia";
import { API } from "./api";
import type { Loading } from "./helpers";
import type { Credentials, DoorSaved, ImageData, SiteConfigModel } from "./model";
@ -55,17 +55,19 @@ export const advent22Store = defineStore("advent22", {
const favicon = await API.request<ImageData>("user/favicon");
const link: HTMLLinkElement =
document.querySelector("link[rel*='icon']") ?? document.createElement("link");
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 {}
} catch { }
try {
const [is_admin, site_config, background_image, user_doors, next_door] = await Promise.all([
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"),
@ -76,7 +78,8 @@ export const advent22Store = defineStore("advent22", {
document.title = site_config.title;
if (site_config.subtitle !== "") document.title += " " + site_config.subtitle;
if (site_config.subtitle !== "")
document.title += " " + site_config.subtitle;
this.site_config = site_config;
this.background_image = background_image;
@ -120,3 +123,9 @@ export const advent22Store = defineStore("advent22", {
},
},
});
if (import.meta.webpackHot) {
import.meta.webpackHot.accept(
acceptHMRUpdate(advent22Store, import.meta.webpackHot),
);
}

View file

@ -1,12 +0,0 @@
import { ref, computed } from "vue";
import { defineStore } from "pinia";
export const useCounterStore = defineStore("counter", () => {
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
function increment() {
count.value++;
}
return { count, doubleCount, increment };
});

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { expect } from "chai";
import { Rectangle } from "@/lib/rects/rectangle";
import { Vector2D } from "@/lib/rects/vector2d";
@ -17,60 +17,56 @@ describe("Rectangle Tests", () => {
width: number,
height: number,
): void {
expect(r.left).toEqual(left);
expect(r.top).toEqual(top);
expect(r.left).to.equal(left);
expect(r.top).to.equal(top);
expect(r.width).toEqual(width);
expect(r.height).toEqual(height);
expect(r.area).toEqual(width * height);
expect(r.width).to.equal(width);
expect(r.height).to.equal(height);
expect(r.area).to.equal(width * height);
expect(r.middle.x).toEqual(left + 0.5 * width);
expect(r.middle.y).toEqual(top + 0.5 * 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", () => {
expect.hasAssertions();
check_rectangle(new Rectangle(), 0, 0, 0, 0);
});
it("should create a rectangle", () => {
expect.hasAssertions();
check_rectangle(r1, 1, 2, 3, 4);
});
it("should create the same rectangle backwards", () => {
expect.hasAssertions();
check_rectangle(r2, 1, 2, 3, 4);
});
it("should compare rectangles", () => {
expect(r1.equals(r2)).toBe(true);
expect(r1.equals(new Rectangle())).toBe(false);
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))).toBe(true);
expect(r1.equals(new Rectangle(v1t, v2t))).to.be.true;
});
it("should contain itself", () => {
expect(r1.contains(v1)).toBe(true);
expect(r1.contains(v2)).toBe(true);
expect(r1.contains(v1)).to.be.true;
expect(r1.contains(v2)).to.be.true;
expect(r1.contains(r1.origin)).toBe(true);
expect(r1.contains(r1.corner)).toBe(true);
expect(r1.contains(r1.middle)).toBe(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))).toBe(false);
expect(r1.contains(new Vector2D(100, 100))).toBe(false);
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", () => {
expect.hasAssertions();
const v = new Vector2D(1, 1);
check_rectangle(r1.update(v1.plus(v), undefined), 2, 3, 2, 3);
@ -87,7 +83,6 @@ describe("Rectangle Tests", () => {
});
it("should move a rectangle", () => {
expect.hasAssertions();
const v = new Vector2D(1, 1);
check_rectangle(r1.move(v), 2, 3, 3, 4);

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { expect } from "chai";
import { Vector2D } from "@/lib/rects/vector2d";
@ -7,35 +7,35 @@ describe("Vector2D Tests", () => {
it("should create a default vector", () => {
const v0 = new Vector2D();
expect(v0.x).toEqual(0);
expect(v0.y).toEqual(0);
expect(v0.x).to.equal(0);
expect(v0.y).to.equal(0);
});
it("should create a vector", () => {
expect(v.x).toEqual(1);
expect(v.y).toEqual(2);
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).toEqual(4);
expect(v2.y).toEqual(6);
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).toEqual(-2);
expect(v2.y).toEqual(-2);
expect(v2.x).to.equal(-2);
expect(v2.y).to.equal(-2);
});
it("should scale vectors", () => {
const v2 = v.scale(3);
expect(v2.x).toEqual(3);
expect(v2.y).toEqual(6);
expect(v2.x).to.equal(3);
expect(v2.y).to.equal(6);
});
it("should compare vectors", () => {
expect(v.equals(v.scale(1))).toBe(true);
expect(v.equals(v.scale(2))).toBe(false);
expect(v.equals(v.scale(1))).to.be.true;
expect(v.equals(v.scale(2))).to.be.false;
});
});

View file

@ -1,14 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"lib": ["es2020", "dom", "dom.iterable", "es2022.object", "es2023.array"],
"paths": {
"@/*": ["./src/*"]
}
}
}

View file

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

View file

@ -1,19 +0,0 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

View file

@ -1,10 +0,0 @@
{
"extends": "./tsconfig.app.json",
"include": ["src/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
"types": ["node", "jsdom"]
}
}

View file

@ -1,45 +0,0 @@
import { fileURLToPath, URL } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
import { analyzer } from "vite-bundle-analyzer";
import { createHtmlPlugin } from "vite-plugin-html";
import vueDevTools from "vite-plugin-vue-devtools";
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
analyzer({
analyzerMode: "static",
}),
createHtmlPlugin({
inject: {
data: {
title: "Kalender-Gewinnspiel",
},
},
}),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
build: {
sourcemap: true,
},
server: {
proxy: {
"/api": {
target: "http://api:8000",
changeOrigin: true,
secure: false,
},
},
},
});

View file

@ -1,14 +0,0 @@
import { fileURLToPath } from "node:url";
import { mergeConfig, defineConfig, configDefaults } from "vitest/config";
import viteConfig from "./vite.config";
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: "jsdom",
exclude: [...configDefaults.exclude, "e2e/**"],
root: fileURLToPath(new URL("./", import.meta.url)),
},
}),
);

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

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

File diff suppressed because it is too large Load diff