Merge branch 'feature/refactoring' into develop

finished refactoring of UI (vue3 w/ composition api)
This commit is contained in:
Jörn-Michael Miehe 2026-02-13 00:34:17 +00:00
commit 6c0c45643a
64 changed files with 4095 additions and 4138 deletions

View file

@ -1,4 +1,4 @@
[flake8] [flake8]
max-line-length = 80 max-line-length = 80
select = C,E,F,W,B,B950 extend-select = B950
extend-ignore = E203, E501 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,22 +1,29 @@
import colorsys import colorsys
import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Self, TypeAlias, cast from typing import Self, TypeAlias, cast
import numpy as np import numpy as np
from PIL import Image, ImageDraw, ImageFont from PIL import Image as PILImage
from PIL import ImageDraw
from PIL.Image import Image, Resampling
from PIL.ImageFont import FreeTypeFont
from .config import Config from .config import Config
_RGB: TypeAlias = tuple[int, int, int] _RGB: TypeAlias = tuple[int, int, int]
_XY: TypeAlias = tuple[float, float] _XY: TypeAlias = tuple[float, float]
_Box: TypeAlias = tuple[int, int, int, int]
_logger = logging.getLogger(__name__)
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class AdventImage: class AdventImage:
img: Image.Image img: Image
@classmethod @classmethod
async def from_img(cls, img: Image.Image, cfg: Config) -> Self: async def from_img(cls, img: Image, cfg: Config) -> Self:
""" """
Einen quadratischen Ausschnitt aus der Mitte des Bilds nehmen Einen quadratischen Ausschnitt aus der Mitte des Bilds nehmen
""" """
@ -42,7 +49,7 @@ class AdventImage:
return cls( return cls(
img.resize( img.resize(
size=(cfg.image.size, cfg.image.size), size=(cfg.image.size, cfg.image.size),
resample=Image.LANCZOS, resample=Resampling.LANCZOS,
) )
) )
@ -50,10 +57,10 @@ class AdventImage:
self, self,
xy: _XY, xy: _XY,
text: str | bytes, text: str | bytes,
font: "ImageFont._Font", font: FreeTypeFont,
anchor: str | None = "mm", anchor: str | None = "mm",
**text_kwargs, **text_kwargs,
) -> "Image._Box | None": ) -> _Box | None:
""" """
Koordinaten (links, oben, rechts, unten) des betroffenen Koordinaten (links, oben, rechts, unten) des betroffenen
Rechtecks bestimmen, wenn das Bild mit einem Text Rechtecks bestimmen, wenn das Bild mit einem Text
@ -61,7 +68,7 @@ class AdventImage:
""" """
# Neues 1-Bit Bild, gleiche Größe # Neues 1-Bit Bild, gleiche Größe
mask = Image.new(mode="1", size=self.img.size, color=0) mask = PILImage.new(mode="1", size=self.img.size)
# Text auf Maske auftragen # Text auf Maske auftragen
ImageDraw.Draw(mask).text( ImageDraw.Draw(mask).text(
@ -78,15 +85,15 @@ class AdventImage:
async def get_average_color( async def get_average_color(
self, self,
box: "Image._Box", box: _Box,
) -> tuple[int, int, int]: ) -> _RGB:
""" """
Durchschnittsfarbe eines rechteckigen Ausschnitts in Durchschnittsfarbe eines rechteckigen Ausschnitts in
einem Bild berechnen einem Bild berechnen
""" """
pixel_data = self.img.crop(box).getdata() pixel_data = np.asarray(self.img.crop(box))
mean_color: np.ndarray = np.mean(pixel_data, axis=0) mean_color: np.ndarray = np.mean(pixel_data, axis=(0, 1))
return cast(_RGB, tuple(mean_color.astype(int))) return cast(_RGB, tuple(mean_color.astype(int)))
@ -94,7 +101,7 @@ class AdventImage:
self, self,
xy: _XY, xy: _XY,
text: str | bytes, text: str | bytes,
font: "ImageFont._Font", font: FreeTypeFont,
anchor: str | None = "mm", anchor: str | None = "mm",
**text_kwargs, **text_kwargs,
) -> None: ) -> None:
@ -108,31 +115,34 @@ class AdventImage:
xy=xy, text=text, font=font, anchor=anchor, **text_kwargs xy=xy, text=text, font=font, anchor=anchor, **text_kwargs
) )
if text_box is not None: if text_box is None:
# Durchschnittsfarbe bestimmen _logger.warning("Konnte Bildbereich nicht finden!")
text_color = await self.get_average_color( return
box=text_box,
)
# etwas heller/dunkler machen # Durchschnittsfarbe bestimmen
tc_h, tc_s, tc_v = colorsys.rgb_to_hsv(*text_color) text_color = await self.get_average_color(
tc_v = int((tc_v - 127) * 0.97) + 127 box=text_box,
)
if tc_v < 127: # etwas heller/dunkler machen
tc_v += 3 tc_h, tc_s, tc_v = colorsys.rgb_to_hsv(*text_color)
tc_v = int((tc_v - 127) * 0.97) + 127
else: if tc_v < 127:
tc_v -= 3 tc_v += 3
text_color = colorsys.hsv_to_rgb(tc_h, tc_s, tc_v) else:
text_color = tuple(int(val) for val in text_color) tc_v -= 3
# Buchstaben verstecken text_color = colorsys.hsv_to_rgb(tc_h, tc_s, tc_v)
ImageDraw.Draw(self.img).text( text_color = tuple(int(val) for val in text_color)
xy=xy,
text=text, # Buchstaben verstecken
font=font, ImageDraw.Draw(self.img).text(
fill=cast(_RGB, text_color), xy=xy,
anchor=anchor, text=text,
**text_kwargs, font=font,
) fill=cast(_RGB, text_color),
anchor=anchor,
**text_kwargs,
)

View file

@ -4,15 +4,10 @@ from markdown import markdown
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import BaseModel, ConfigDict, field_validator
from .dav.webdav import WebDAV from .dav.webdav import WebDAV
from .settings import SETTINGS from .settings import SETTINGS, Credentials
from .transformed_string import TransformedString from .transformed_string import TransformedString
class User(BaseModel):
name: str
password: str
class Site(BaseModel): class Site(BaseModel):
model_config = ConfigDict(validate_default=True) model_config = ConfigDict(validate_default=True)
@ -60,7 +55,7 @@ class Image(BaseModel):
class Config(BaseModel): class Config(BaseModel):
# Login-Daten für Admin-Modus # Login-Daten für Admin-Modus
admin: User admin: Credentials
# Lösungswort # Lösungswort
solution: TransformedString solution: TransformedString

View file

@ -16,8 +16,8 @@ class WebDAV:
_webdav_client = WebDAVclient( _webdav_client = WebDAVclient(
{ {
"webdav_hostname": SETTINGS.webdav.url, "webdav_hostname": SETTINGS.webdav.url,
"webdav_login": SETTINGS.webdav.username, "webdav_login": SETTINGS.webdav.auth.username,
"webdav_password": SETTINGS.webdav.password, "webdav_password": SETTINGS.webdav.auth.password,
} }
) )

View file

@ -5,7 +5,9 @@ from io import BytesIO
from typing import cast from typing import cast
from fastapi import Depends from fastapi import Depends
from PIL import Image, ImageFont from PIL import ImageFont
from PIL.Image import Image
from PIL.ImageFont import FreeTypeFont
from .advent_image import _XY, AdventImage from .advent_image import _XY, AdventImage
from .calendar_config import CalendarConfig, get_calendar_config from .calendar_config import CalendarConfig, get_calendar_config
@ -22,6 +24,8 @@ from .helpers import (
set_len, set_len,
) )
RE_NUM = re.compile(r"/(\d+)\.", flags=re.IGNORECASE)
async def get_all_sorted_days( async def get_all_sorted_days(
cal_cfg: CalendarConfig = Depends(get_calendar_config), cal_cfg: CalendarConfig = Depends(get_calendar_config),
@ -107,11 +111,10 @@ async def get_all_manual_image_names(
Bilder: "manual" zuordnen Bilder: "manual" zuordnen
""" """
num_re = re.compile(r"/(\d+)\.", flags=re.IGNORECASE)
return { return {
int(num_match.group(1)): name int(num_match.group(1)): name
for name in manual_image_names for name in manual_image_names
if (num_match := num_re.search(name)) is not None if (num_match := RE_NUM.search(name)) is not None
} }
@ -138,7 +141,7 @@ class TTFont:
size: int = 50 size: int = 50
@property @property
async def font(self) -> "ImageFont._Font": async def font(self) -> FreeTypeFont:
return ImageFont.truetype( return ImageFont.truetype(
font=BytesIO(await WebDAV.read_bytes(self.file_name)), font=BytesIO(await WebDAV.read_bytes(self.file_name)),
size=100, size=100,
@ -169,7 +172,7 @@ async def gen_day_auto_image(
auto_image_names: dict[int, str], auto_image_names: dict[int, str],
day_parts: dict[int, str], day_parts: dict[int, str],
ttfonts: list[TTFont], ttfonts: list[TTFont],
) -> Image.Image: ) -> Image:
""" """
Automatisch generiertes Bild erstellen Automatisch generiertes Bild erstellen
""" """
@ -200,7 +203,7 @@ async def get_day_image(
auto_image_names: dict[int, str] = Depends(get_all_auto_image_names), auto_image_names: dict[int, str] = Depends(get_all_auto_image_names),
day_parts: dict[int, str] = Depends(get_all_parts), day_parts: dict[int, str] = Depends(get_all_parts),
ttfonts: list[TTFont] = Depends(get_all_ttfonts), ttfonts: list[TTFont] = Depends(get_all_ttfonts),
) -> Image.Image | None: ) -> Image | None:
""" """
Bild für einen Tag abrufen Bild für einen Tag abrufen
""" """

View file

@ -1,3 +1,4 @@
import base64
import itertools import itertools
import random import random
import re import re
@ -5,8 +6,9 @@ from datetime import date, datetime, timedelta
from io import BytesIO from io import BytesIO
from typing import Any, Awaitable, Callable, Iterable, Self, Sequence, TypeVar from typing import Any, Awaitable, Callable, Iterable, Self, Sequence, TypeVar
from fastapi.responses import StreamingResponse from PIL import Image as PILImage
from PIL import Image from PIL.Image import Image, Resampling
from pydantic import BaseModel
from .config import get_config from .config import get_config
from .dav.webdav import WebDAV from .dav.webdav import WebDAV
@ -103,7 +105,7 @@ list_images_manual = list_helper("/images_manual", RE_IMG)
list_fonts = list_helper("/files", RE_TTF) list_fonts = list_helper("/files", RE_TTF)
async def load_image(file_name: str) -> Image.Image: async def load_image(file_name: str) -> Image:
""" """
Versuche, Bild aus Datei zu laden Versuche, Bild aus Datei zu laden
""" """
@ -111,28 +113,54 @@ async def load_image(file_name: str) -> Image.Image:
if not await WebDAV.exists(file_name): if not await WebDAV.exists(file_name):
raise RuntimeError(f"DAV-File {file_name} does not exist!") raise RuntimeError(f"DAV-File {file_name} does not exist!")
return Image.open(BytesIO(await WebDAV.read_bytes(file_name))) return PILImage.open(BytesIO(await WebDAV.read_bytes(file_name)))
async def api_return_ico(img: Image.Image) -> StreamingResponse: class ImageData(BaseModel):
width: int
height: int
aspect_ratio: float
data_url: str
@classmethod
def create(
cls,
*,
media_type: str,
content: BytesIO,
width: int,
height: int,
) -> Self:
img_data = base64.b64encode(content.getvalue()).decode("utf-8")
return cls(
width=width,
height=height,
aspect_ratio=width / height,
data_url=f"data:{media_type};base64,{img_data}",
)
async def api_return_ico(img: Image) -> ImageData:
""" """
ICO-Bild mit API zurückgeben ICO-Bild mit API zurückgeben
""" """
# JPEG-Daten in Puffer speichern # ICO-Daten in Puffer speichern (256px)
img_buffer = BytesIO() img_buffer = BytesIO()
img.resize(size=(256, 256), resample=Image.LANCZOS) img.resize(size=(256, 256), resample=Resampling.LANCZOS)
img.save(img_buffer, format="ICO") img.save(img_buffer, format="ICO")
img_buffer.seek(0)
# zurückgeben # zurückgeben
return StreamingResponse( return ImageData.create(
media_type="image/x-icon", media_type="image/x-icon",
content=img_buffer, content=img_buffer,
width=img.width,
height=img.height,
) )
async def api_return_jpeg(img: Image.Image) -> StreamingResponse: async def api_return_jpeg(img: Image) -> ImageData:
""" """
JPEG-Bild mit API zurückgeben JPEG-Bild mit API zurückgeben
""" """
@ -140,12 +168,13 @@ async def api_return_jpeg(img: Image.Image) -> StreamingResponse:
# JPEG-Daten in Puffer speichern # JPEG-Daten in Puffer speichern
img_buffer = BytesIO() img_buffer = BytesIO()
img.save(img_buffer, format="JPEG", quality=85) img.save(img_buffer, format="JPEG", quality=85)
img_buffer.seek(0)
# zurückgeben # zurückgeben
return StreamingResponse( return ImageData.create(
media_type="image/jpeg", media_type="image/jpeg",
content=img_buffer, content=img_buffer,
width=img.width,
height=img.height,
) )

View file

@ -6,6 +6,11 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
T = TypeVar("T") T = TypeVar("T")
class Credentials(BaseModel):
username: str = ""
password: str = ""
class DavSettings(BaseModel): class DavSettings(BaseModel):
""" """
Connection to a DAV server. Connection to a DAV server.
@ -16,8 +21,10 @@ class DavSettings(BaseModel):
path: str = "/remote.php/webdav" path: str = "/remote.php/webdav"
prefix: str = "/advent22" prefix: str = "/advent22"
username: str = "advent22_user" auth: Credentials = Credentials(
password: str = "password" username="advent22_user",
password="password",
)
cache_ttl: int = 60 * 10 cache_ttl: int = 60 * 10
config_filename: str = "config.toml" config_filename: str = "config.toml"

View file

@ -21,7 +21,7 @@ async def user_is_admin(
username_correct = secrets.compare_digest( username_correct = secrets.compare_digest(
credentials.username.lower(), credentials.username.lower(),
cfg.admin.name.lower(), cfg.admin.username.lower(),
) )
password_correct = secrets.compare_digest( password_correct = secrets.compare_digest(
credentials.password, credentials.password,

View file

@ -5,7 +5,11 @@ from pydantic import BaseModel
from advent22_api.core.helpers import EventDates from advent22_api.core.helpers import EventDates
from ..core.calendar_config import CalendarConfig, DoorsSaved, get_calendar_config from ..core.calendar_config import (
CalendarConfig,
DoorsSaved,
get_calendar_config,
)
from ..core.config import Config, Image, get_config from ..core.config import Config, Image, get_config
from ..core.depends import ( from ..core.depends import (
TTFont, TTFont,
@ -14,7 +18,7 @@ from ..core.depends import (
get_all_parts, get_all_parts,
get_all_ttfonts, get_all_ttfonts,
) )
from ..core.settings import SETTINGS, RedisSettings from ..core.settings import SETTINGS, Credentials, RedisSettings
from ._security import require_admin, user_is_admin from ._security import require_admin, user_is_admin
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@ -170,24 +174,16 @@ async def put_doors(
await cal_cfg.change(cfg) await cal_cfg.change(cfg)
@router.get("/dav_credentials") @router.get("/credentials/{name}")
async def get_dav_credentials( async def get_credentials(
_: None = Depends(require_admin), name: str,
) -> 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), _: None = Depends(require_admin),
cfg: Config = Depends(get_config), cfg: Config = Depends(get_config),
) -> tuple[str, str]: ) -> Credentials:
"""
Zugangsdaten für Admin-UI
"""
return cfg.admin.name, cfg.admin.password if name == "dav":
return SETTINGS.webdav.auth
elif name == "ui":
return cfg.admin
else:
return Credentials()

View file

@ -1,25 +1,31 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse from PIL.Image import Image
from PIL import Image
from ..core.calendar_config import CalendarConfig, DoorsSaved, get_calendar_config from ..core.calendar_config import (
CalendarConfig,
DoorsSaved,
get_calendar_config,
)
from ..core.config import Config, Site, get_config from ..core.config import Config, Site, get_config
from ..core.depends import get_all_event_dates, get_day_image 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 ..core.helpers import (
EventDates,
ImageData,
api_return_ico,
api_return_jpeg,
load_image,
)
from ._security import user_can_view_day, user_is_admin, user_visible_days from ._security import user_can_view_day, user_is_admin, user_visible_days
router = APIRouter(prefix="/user", tags=["user"]) router = APIRouter(prefix="/user", tags=["user"])
@router.get( @router.get("/background_image")
"/background_image",
response_class=StreamingResponse,
)
async def get_background_image( async def get_background_image(
cal_cfg: CalendarConfig = Depends(get_calendar_config), cal_cfg: CalendarConfig = Depends(get_calendar_config),
) -> StreamingResponse: ) -> ImageData:
""" """
Hintergrundbild laden Hintergrundbild laden
""" """
@ -27,13 +33,10 @@ async def get_background_image(
return await api_return_jpeg(await load_image(f"files/{cal_cfg.background}")) return await api_return_jpeg(await load_image(f"files/{cal_cfg.background}"))
@router.get( @router.get("/favicon")
"/favicon",
response_class=StreamingResponse,
)
async def get_favicon( async def get_favicon(
cal_cfg: CalendarConfig = Depends(get_calendar_config), cal_cfg: CalendarConfig = Depends(get_calendar_config),
) -> StreamingResponse: ) -> ImageData:
""" """
Favicon laden Favicon laden
""" """
@ -68,15 +71,12 @@ async def get_doors(
return [door for door in cal_cfg.doors if door.day in visible_days] return [door for door in cal_cfg.doors if door.day in visible_days]
@router.get( @router.get("/image_{day}")
"/image_{day}",
response_class=StreamingResponse,
)
async def get_image_for_day( async def get_image_for_day(
user_can_view: bool = Depends(user_can_view_day), user_can_view: bool = Depends(user_can_view_day),
is_admin: bool = Depends(user_is_admin), is_admin: bool = Depends(user_is_admin),
image: Image.Image | None = Depends(get_day_image), image: Image | None = Depends(get_day_image),
) -> StreamingResponse: ) -> ImageData:
""" """
Bild für einen Tag erstellen Bild für einen Tag erstellen
""" """

View file

@ -2,15 +2,19 @@
// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/javascript-node // https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/javascript-node
{ {
"name": "Advent22 UI", "name": "Advent22 UI",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile // 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", "image": "mcr.microsoft.com/devcontainers/javascript-node:4-20-trixie",
// Features to add to the dev container. More info: https://containers.dev/features. // Features to add to the dev container. More info: https://containers.dev/features.
"features": { "features": {
"ghcr.io/devcontainers/features/git-lfs:1": {},
"ghcr.io/devcontainers-extra/features/apt-get-packages:1": { "ghcr.io/devcontainers-extra/features/apt-get-packages:1": {
"packages": "git-flow, git-lfs" "packages": "git-flow"
}, },
"ghcr.io/devcontainers-extra/features/vue-cli:2": {} "ghcr.io/devcontainers-extra/features/vue-cli:2": {}
}, },
// Configure tool-specific properties. // Configure tool-specific properties.
"customizations": { "customizations": {
// Configure properties specific to VS Code. // Configure properties specific to VS Code.
@ -29,11 +33,16 @@
] ]
} }
}, },
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install", // "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. // Use 'postStartCommand' to run commands after the container is started.
"remoteUser": "node" "postStartCommand": "npx --yes update-browserslist-db@latest && yarn install --production false"
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
} }

View file

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

View file

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

View file

@ -12,4 +12,4 @@
"webRoot": "${workspaceFolder}" "webRoot": "${workspaceFolder}"
} }
] ]
} }

View file

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

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

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

View file

@ -1,24 +1,29 @@
# advent22_ui # advent22_ui
## Project setup ## Project setup
``` ```
yarn install yarn install
``` ```
### Compiles and hot-reloads for development ### Compiles and hot-reloads for development
``` ```
yarn serve yarn serve
``` ```
### Compiles and minifies for production ### Compiles and minifies for production
``` ```
yarn build yarn build
``` ```
### Lints and fixes files ### Lints and fixes files
``` ```
yarn lint yarn lint
``` ```
### Customize configuration ### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/). See [Configuration Reference](https://cli.vuejs.org/config/).

View file

@ -3,45 +3,44 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8080", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit", "test:unit": "vue-cli-service test:unit",
"test:unit-watch": "vue-cli-service test:unit --watch", "test:unit-watch": "vue-cli-service test:unit --watch",
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint",
"ui": "vue ui --host 0.0.0.0 --headless"
}, },
"devDependencies": { "devDependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/vue-fontawesome": "^3.1.3",
"@fortawesome/vue-fontawesome": "^3.0.6", "@types/chai": "^5.2.3",
"@types/chai": "^4.3.14", "@types/luxon": "^3.7.1",
"@types/luxon": "^3.4.2", "@types/mocha": "^10.0.10",
"@types/mocha": "^10.0.6", "@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^8.55.0",
"@typescript-eslint/parser": "^7.3.1", "@vue/cli-plugin-babel": "^5.0.9",
"@vue/cli-plugin-babel": "~5.0.0", "@vue/cli-plugin-eslint": "^5.0.9",
"@vue/cli-plugin-eslint": "~5.0.0", "@vue/cli-plugin-typescript": "^5.0.9",
"@vue/cli-plugin-typescript": "~5.0.0", "@vue/cli-plugin-unit-mocha": "^5.0.9",
"@vue/cli-plugin-unit-mocha": "~5.0.0", "@vue/cli-service": "^5.0.9",
"@vue/cli-service": "~5.0.0",
"@vue/eslint-config-typescript": "^13.0.0", "@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.5", "@vue/test-utils": "^2.4.6",
"@vueuse/core": "^10.9.0", "@vue/tsconfig": "^0.8.1",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"axios": "^1.6.8", "axios": "^1.13.5",
"bulma": "^0.9.4", "bulma": "^1.0.4",
"bulma-prefers-dark": "^0.1.0-beta.1",
"bulma-toast": "2.4.3", "bulma-toast": "2.4.3",
"chai": "^4.3.10", "chai": "^6.2.2",
"core-js": "^3.36.1", "core-js": "^3.48.0",
"eslint": "^8.57.0", "eslint": "^8.57.1",
"eslint-plugin-vue": "^9.23.0", "eslint-plugin-vue": "^9.33.0",
"luxon": "^3.4.4", "luxon": "^3.7.2",
"pinia": "^2.1.7", "pinia": "^3.0.4",
"sass": "^1.72.0", "sass": "~1.94.3",
"sass-loader": "^14.1.1", "sass-loader": "^16.0.0",
"typescript": "~5.4.3", "typescript": "^5.9.3",
"vue": "^3.4.21", "vue": "^3.5.25",
"vue-class-component": "^8.0.0-0" "vue-cli-plugin-webpack-bundle-analyzer": "^4.0.0"
} }
} }

View file

@ -1,30 +1,38 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang=""> <html lang="">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<title><%= htmlWebpackPlugin.options.title %></title> <title><%= htmlWebpackPlugin.options.title %></title>
<!-- Matomo --> <!-- Matomo -->
<script> <script>
let _paq = window._paq = window._paq || []; let _paq = (window._paq = window._paq || []);
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */ /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['trackPageView']); _paq.push(["trackPageView"]);
_paq.push(['enableLinkTracking']); _paq.push(["enableLinkTracking"]);
(function () { (function () {
const u = "https://stats.kiwi.lenaisten.de/"; const u = "https://stats.kiwi.lenaisten.de/";
_paq.push(['setTrackerUrl', u + 'matomo.php']); _paq.push(["setTrackerUrl", u + "matomo.php"]);
_paq.push(['setSiteId', '10']); _paq.push(["setSiteId", "10"]);
const d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0]; const d = document,
g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s); g = d.createElement("script"),
s = d.getElementsByTagName("script")[0];
g.async = true;
g.src = u + "matomo.js";
s.parentNode.insertBefore(g, s);
})(); })();
</script> </script>
<!-- End Matomo Code --> <!-- End Matomo Code -->
</head> </head>
<body> <body>
<noscript> <noscript>
<strong>Es tut uns leid, aber <%= htmlWebpackPlugin.options.title %> funktioniert nicht richtig ohne JavaScript. Bitte aktivieren Sie es, um fortzufahren.</strong> <strong
>Es tut uns leid, aber <%= htmlWebpackPlugin.options.title %>
funktioniert nicht richtig ohne JavaScript. Bitte aktivieren Sie es, um
fortzufahren.</strong
>
</noscript> </noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->

View file

@ -7,7 +7,18 @@
</section> </section>
<section class="section px-3"> <section class="section px-3">
<div class="container"> <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" /> <AdminView v-if="store.is_admin" />
<UserView v-else /> <UserView v-else />
</div> </div>
@ -22,36 +33,25 @@
</div> </div>
<div class="level-right"> <div class="level-right">
<div class="level-item"> <div class="level-item">
<TouchButton class="tag is-warning" /> <TouchButton class="is-small is-warning" />
</div> </div>
<div class="level-item"> <div class="level-item">
<AdminButton class="tag is-link is-outlined" /> <AdminButton class="is-small is-link is-outlined" />
</div> </div>
</div> </div>
</div> </div>
</footer> </footer>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Options, Vue } from "vue-class-component"; import { advent22Store } from "./lib/store";
import { advent22Store } from "./plugins/store";
import AdminView from "./components/admin/AdminView.vue"; import AdminView from "./components/admin/AdminView.vue";
import AdminButton from "./components/AdminButton.vue"; import AdminButton from "./components/AdminButton.vue";
import TouchButton from "./components/TouchButton.vue"; import TouchButton from "./components/TouchButton.vue";
import UserView from "./components/UserView.vue"; import UserView from "./components/UserView.vue";
@Options({ const store = advent22Store();
components: {
AdminView,
AdminButton,
TouchButton,
UserView,
},
})
export default class extends Vue {
public readonly store = advent22Store();
}
</script> </script>
<style> <style>

View file

@ -1,22 +1,14 @@
@charset "utf-8"; @charset "utf-8";
@use "sass:map";
//===================== //=====================
// custom color scheme // custom color scheme
//===================== //=====================
$advent22-colors: ( $colors: (
"primary": #945DE1, "primary": #945de1,
"link": #64B4BD, "link": #64b4bd,
"info": #8C4E80, "info": #8c4e80,
"success": #7E8E2B, "success": #7e8e2b,
"warning": #F6CA6B, "warning": #f6ca6b,
"danger": #C5443B, "danger": #c5443b,
); );
$primary: map.get($advent22-colors, "primary");
$link: map.get($advent22-colors, "link");
$info: map.get($advent22-colors, "info");
$success: map.get($advent22-colors, "success");
$warning: map.get($advent22-colors, "warning");
$danger: map.get($advent22-colors, "danger");

View file

@ -3,54 +3,50 @@
<BulmaButton <BulmaButton
v-bind="$attrs" v-bind="$attrs"
:icon="'fa-solid fa-toggle-' + (store.is_admin ? 'on' : 'off')" :icon="['fas', store.is_admin ? 'fa-toggle-on' : 'fa-toggle-off']"
:busy="is_busy" :busy="is_busy"
text="Admin" text="Admin"
@click.left="on_click" @click.left="on_click"
/> />
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Credentials } from "@/lib/api"; import { APIError } from "@/lib/api_error";
import { advent22Store } from "@/plugins/store"; import type { Credentials } from "@/lib/model";
import { Options, Vue } from "vue-class-component"; import { advent22Store } from "@/lib/store";
import { ref } from "vue";
import BulmaButton from "./bulma/Button.vue"; import BulmaButton from "./bulma/Button.vue";
import LoginModal from "./LoginModal.vue"; import LoginModal from "./LoginModal.vue";
@Options({ const modal_visible = ref(false);
components: { const is_busy = ref(false);
BulmaButton, const store = advent22Store();
LoginModal,
},
})
export default class extends Vue {
public modal_visible = false;
public is_busy = false;
public readonly store = advent22Store();
public on_click() { function on_click(): void {
if (this.store.is_admin) { if (store.is_admin) {
this.store.logout(); store.logout();
} else { } else {
// show login modal // show login modal
this.is_busy = true; is_busy.value = true;
this.modal_visible = true; modal_visible.value = true;
}
}
public on_submit(creds: Credentials) {
this.modal_visible = false;
this.store
.login(creds)
.catch(this.store.alert_user_error)
.finally(() => (this.is_busy = false));
}
public on_cancel() {
this.modal_visible = false;
this.is_busy = false;
} }
} }
async function on_submit(creds: Credentials): Promise<void> {
modal_visible.value = false;
try {
await store.login(creds);
} catch (error) {
APIError.alert(error);
} finally {
is_busy.value = false;
}
}
function on_cancel(): void {
modal_visible.value = false;
is_busy.value = false;
}
</script> </script>

View file

@ -1,7 +1,8 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template> <template>
<MultiModal @handle="modal_handle" /> <MultiModal @handle="on_modal_handle" />
<BulmaToast @handle="toast_handle" class="content"> <BulmaToast @handle="on_toast_handle" class="content">
<p> <p>
Du hast noch keine Türchen geöffnet, vielleicht gibt es ein Anzeigeproblem Du hast noch keine Türchen geöffnet, vielleicht gibt es ein Anzeigeproblem
in Deinem Webbrowser? in Deinem Webbrowser?
@ -29,14 +30,14 @@
<figure> <figure>
<div class="image is-unselectable"> <div class="image is-unselectable">
<img :src="store.calendar_background_image" /> <img :src="unwrap_loading(store.background_image).data_url" />
<ThouCanvas> <ThouCanvas>
<CalendarDoor <CalendarDoor
v-for="(door, index) in doors" v-for="(door, index) in doors"
:key="`door-${index}`" :key="`door-${index}`"
:door="door" :door="door"
:visible="store.is_touch_device" :visible="store.is_touch_device"
:title="$advent22.name_door(door.day)" :title="name_door(door.day)"
@click="door_click(door.day)" @click="door_click(door.day)"
style="cursor: pointer" style="cursor: pointer"
/> />
@ -45,77 +46,65 @@
</figure> </figure>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Door } from "@/lib/door"; import { API } from "@/lib/api";
import { advent22Store } from "@/plugins/store"; import { APIError } from "@/lib/api_error";
import { Options, Vue } from "vue-class-component"; import { type VueLike, name_door, unwrap_loading } from "@/lib/helpers";
import type { ImageData } from "@/lib/model";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store";
import MultiModal from "./MultiModal.vue"; import { onBeforeUnmount } from "vue";
import MultiModal, { type HMultiModal } from "./MultiModal.vue";
import BulmaButton from "./bulma/Button.vue"; import BulmaButton from "./bulma/Button.vue";
import BulmaToast from "./bulma/Toast.vue"; import BulmaToast, { type HBulmaToast } from "./bulma/Toast.vue";
import CalendarDoor from "./calendar/CalendarDoor.vue"; import CalendarDoor from "./calendar/CalendarDoor.vue";
import ThouCanvas from "./calendar/ThouCanvas.vue"; import ThouCanvas from "./calendar/ThouCanvas.vue";
@Options({ defineProps<{
components: { doors: VueLike<Door>[];
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; const store = advent22Store();
public toast?: BulmaToast; let modal: HMultiModal | undefined;
private toast_timeout?: number; let toast: HBulmaToast | undefined;
let toast_timeout: number | undefined;
public modal_handle(modal: MultiModal) { function on_modal_handle(handle: HMultiModal): void {
this.multi_modal = modal; modal = handle;
} }
public toast_handle(toast: BulmaToast) { function on_toast_handle(handle: HBulmaToast): void {
this.toast = toast; toast = handle;
if (this.store.is_touch_device) return; if (store.is_touch_device) return;
this.store.when_initialized(() => { store.when_initialized(() => {
this.toast_timeout = setTimeout(() => { toast_timeout = window.setTimeout(() => {
if (this.store.user_doors.length === 0) return; if (store.user_doors.length === 0) return;
if (this.store.is_touch_device) return; if (store.is_touch_device) return;
this.toast!.show({ duration: 600000, type: "is-warning" }); toast!.show({ duration: 600000, type: "is-warning" });
}, 10e3); }, 10e3);
}); });
} }
public door_click(day: number) { async function door_click(day: number): Promise<void> {
if (this.toast_timeout !== undefined) clearTimeout(this.toast_timeout); window.clearTimeout(toast_timeout);
this.toast?.hide(); toast?.hide();
if (this.multi_modal === undefined) return; if (modal === undefined) return;
this.multi_modal.show_progress(); modal.show_loading();
this.$advent22 try {
.api_get_blob(`user/image_${day}`) const day_image = await API.request<ImageData>(`user/image_${day}`);
.then((image_src) => { modal.show_image(day_image.data_url, name_door(day));
this.multi_modal!.show_image(image_src, this.$advent22.name_door(day)); } catch (error) {
}) APIError.alert(error);
.catch((error) => { modal.hide();
this.store.alert_user_error(error);
this.multi_modal!.hide();
});
}
public beforeUnmount(): void {
this.toast?.hide();
} }
} }
onBeforeUnmount(() => toast?.hide());
</script> </script>

View file

@ -2,31 +2,27 @@
{{ string_repr }} {{ string_repr }}
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Duration } from "luxon"; import { Duration } from "luxon";
import { Options, Vue } from "vue-class-component"; import { onBeforeUnmount, onMounted, ref } from "vue";
@Options({ const props = withDefaults(
props: { defineProps<{
until: Number, until: number;
tick_time: { tick_time?: number;
type: Number, }>(),
default: 200, { tick_time: 200 },
}, );
},
})
export default class extends Vue {
private until!: number;
private tick_time!: number;
private interval_id: number | null = null; let interval_id: number | undefined;
public string_repr = ""; const string_repr = ref("");
private tick(): void { onMounted(() => {
const distance_ms = this.until - Date.now(); function tick(): void {
const distance_ms = props.until - Date.now();
if (distance_ms <= 0) { if (distance_ms <= 0) {
this.string_repr = "Jetzt!"; string_repr.value = "Jetzt!";
return; return;
} }
@ -35,21 +31,18 @@ export default class extends Vue {
const d_hms = distance.minus(d_days).shiftTo("hour", "minute", "second"); const d_hms = distance.minus(d_days).shiftTo("hour", "minute", "second");
if (d_days.days > 0) { if (d_days.days > 0) {
this.string_repr = d_days.toHuman() + " "; string_repr.value = d_days.toHuman() + " ";
} else { } else {
this.string_repr = ""; string_repr.value = "";
} }
this.string_repr += d_hms.toFormat("hh:mm:ss"); string_repr.value += d_hms.toFormat("hh:mm:ss");
} }
public mounted(): void { tick();
this.tick(); interval_id = window.setInterval(tick, props.tick_time);
this.interval_id = window.setInterval(this.tick, this.tick_time); });
}
public beforeUnmount(): void { onBeforeUnmount(() => {
if (this.interval_id === null) return; window.clearInterval(interval_id);
window.clearInterval(this.interval_id); });
}
}
</script> </script>

View file

@ -16,7 +16,7 @@
ref="username_input" ref="username_input"
class="input" class="input"
type="text" type="text"
v-model="username" v-model="creds.username"
/> />
</div> </div>
</div> </div>
@ -24,7 +24,7 @@
<div class="field"> <div class="field">
<label class="label">Passwort</label> <label class="label">Passwort</label>
<div class="control"> <div class="control">
<input class="input" type="password" v-model="password" /> <input class="input" type="password" v-model="creds.password" />
</div> </div>
</div> </div>
</section> </section>
@ -33,13 +33,13 @@
<BulmaButton <BulmaButton
class="is-success" class="is-success"
@click.left="submit" @click.left="submit"
icon="fa-solid fa-unlock" :icon="['fas', 'fa-unlock']"
text="Login" text="Login"
/> />
<BulmaButton <BulmaButton
class="is-danger" class="is-danger"
@click.left="cancel" @click.left="cancel"
icon="fa-solid fa-circle-xmark" :icon="['fas', 'fa-circle-xmark']"
text="Abbrechen" text="Abbrechen"
/> />
</footer> </footer>
@ -47,48 +47,47 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Options, Vue } from "vue-class-component"; import { wait_for } from "@/lib/helpers";
import type { Credentials } from "@/lib/model";
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue";
import BulmaButton from "./bulma/Button.vue"; import BulmaButton from "./bulma/Button.vue";
@Options({ const username_input = useTemplateRef("username_input");
components: {
BulmaButton,
},
props: {
visible: Boolean,
},
emits: ["cancel", "submit"],
})
export default class extends Vue {
public username = "";
public password = "";
private on_keydown(e: KeyboardEvent) { const emit = defineEmits<{
if (e.key == "Enter") this.submit(); (event: "submit", creds: Credentials): void;
else if (e.key == "Escape") this.cancel(); (event: "cancel"): void;
} }>();
public mounted(): void { const creds = ref<Credentials>({
window.addEventListener("keydown", this.on_keydown); username: "",
password: "",
});
this.$nextTick(() => { function submit(): void {
if (!(this.$refs.username_input instanceof HTMLElement)) return; emit("submit", creds.value);
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");
}
} }
function cancel(): void {
emit("cancel");
}
onMounted(() => {
const on_keydown = (e: KeyboardEvent) => {
if (e.key === "Enter") submit();
else if (e.key === "Escape") cancel();
};
window.addEventListener("keydown", on_keydown);
wait_for(
() => username_input.value !== null,
() => username_input.value!.focus(),
);
onBeforeUnmount(() => {
window.removeEventListener("keydown", on_keydown);
});
});
</script> </script>

View file

@ -1,83 +1,79 @@
<template> <template>
<div class="modal is-active" v-if="active" @click="dismiss()"> <div v-if="state.show !== 'none'" class="modal is-active" @click="dismiss()">
<div class="modal-background" /> <div class="modal-background" />
<div class="modal-content" style="max-height: 100vh; max-width: 95vw"> <div class="modal-content" style="max-height: 100vh; max-width: 95vw">
<template v-if="progress"> <template v-if="state.show === 'loading'">
<progress class="progress is-primary" max="100" /> <progress class="progress is-primary" max="100" />
</template> </template>
<template v-else> <template v-else-if="state.show === 'image'">
<figure> <figure>
<figcaption class="tag is-primary"> <figcaption class="tag is-primary">
{{ caption }} {{ state.caption }}
</figcaption> </figcaption>
<div class="image is-square"> <div class="image is-square">
<img :src="image_src" alt="Kalender-Bild" /> <img :src="state.src" alt="Kalender-Bild" />
</div> </div>
</figure> </figure>
</template> </template>
</div> </div>
<button <button
v-if="!progress" v-if="state.show !== 'loading'"
class="modal-close is-large has-background-primary" class="modal-close is-large has-background-primary"
/> />
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Options, Vue } from "vue-class-component"; import { onBeforeUnmount, onMounted, ref } from "vue";
@Options({ type ModalState =
emits: ["handle"], | { show: "none" }
}) | { show: "loading" }
export default class extends Vue { | { show: "image"; src: string; caption: string };
public active = false;
public progress = false;
public image_src = "";
public caption = "";
private on_keydown(e: KeyboardEvent) { const state = ref<ModalState>({ show: "none" });
if (e.key == "Escape") this.dismiss();
}
public created(): void { export type HMultiModal = {
this.$emit("handle", this); show_image(src: string, caption: string): void;
} show_loading(): void;
hide(): void;
};
public mounted(): void { const emit = defineEmits<{
window.addEventListener("keydown", this.on_keydown); (event: "handle", handle: HMultiModal): void;
} }>();
public beforeUnmount(): void { function hide(): void {
window.removeEventListener("keydown", this.on_keydown); state.value = { show: "none" };
} }
public show() { function dismiss(): void {
this.active = true; if (state.value.show !== "loading") {
} hide();
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();
} }
} }
onMounted(() => {
emit("handle", {
show_image(src: string, caption: string = ""): void {
state.value = { show: "image", src: src, caption: caption };
},
show_loading(): void {
state.value = { show: "loading" };
},
hide,
});
const on_keydown = (e: KeyboardEvent) => {
if (e.key === "Escape") dismiss();
};
window.addEventListener("keydown", on_keydown);
onBeforeUnmount(() => {
window.removeEventListener("keydown", on_keydown);
});
});
</script> </script>

View file

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

View file

@ -1,41 +1,29 @@
<template> <template>
<template v-if="store.is_initialized === true"> <Calendar :doors="store.user_doors" />
<Calendar :doors="store.user_doors" /> <hr />
<hr /> <div class="content" v-html="store.site_config.content" />
<div class="content" v-html="store.site_config.content" /> <div class="content has-text-primary">
<div class="content has-text-primary"> <template v-if="store.next_door_target === null">
<template v-if="store.next_door_target === null"> Alle {{ store.user_doors.length }} Türchen offen!
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>
<template v-else> <template v-else>
<template v-if="store.user_doors.length === 0"> {{ store.user_doors.length }} Türchen offen. Zeit bis zum nächsten
Zeit bis zum ersten Türchen: 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> </template>
</div> <CountDown :until="store.next_door_target" />
</template> </template>
<progress v-else class="progress is-primary" max="100" /> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { advent22Store } from "@/plugins/store"; import { advent22Store } from "@/lib/store";
import { Options, Vue } from "vue-class-component";
import Calendar from "./Calendar.vue"; import Calendar from "./Calendar.vue";
import CountDown from "./CountDown.vue"; import CountDown from "./CountDown.vue";
@Options({ const store = advent22Store();
components: {
Calendar,
CountDown,
},
})
export default class extends Vue {
public readonly store = advent22Store();
}
</script> </script>

View file

@ -2,21 +2,19 @@
<ConfigView /> <ConfigView />
<CalendarAssistant /> <CalendarAssistant />
<DoorMapEditor /> <DoorMapEditor />
<BulmaDrawer header="Vorschau" :opening="store.update" refreshable>
<UserView />
</BulmaDrawer>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Options, Vue } from "vue-class-component"; import { advent22Store } from "@/lib/store";
import UserView from "../UserView.vue";
import BulmaDrawer from "../bulma/Drawer.vue";
import CalendarAssistant from "./CalendarAssistant.vue"; import CalendarAssistant from "./CalendarAssistant.vue";
import ConfigView from "./ConfigView.vue"; import ConfigView from "./ConfigView.vue";
import DoorMapEditor from "./DoorMapEditor.vue"; import DoorMapEditor from "./DoorMapEditor.vue";
@Options({ const store = advent22Store();
components: {
ConfigView,
CalendarAssistant,
DoorMapEditor,
},
})
export default class extends Vue {}
</script> </script>

View file

@ -1,7 +1,7 @@
<template> <template>
<MultiModal @handle="modal_handle" /> <MultiModal @handle="on_modal_handle" />
<BulmaDrawer header="Kalender-Assistent" @open="on_open" refreshable> <BulmaDrawer header="Kalender-Assistent" :opening="on_open" refreshable>
<div class="card-content"> <div class="card-content">
<div class="content"> <div class="content">
<p>Hervorgehobenen Tagen wurde kein Buchstabe zugewiesen.</p> <p>Hervorgehobenen Tagen wurde kein Buchstabe zugewiesen.</p>
@ -35,9 +35,9 @@
v-for="(data, day) in day_data" v-for="(data, day) in day_data"
:key="`btn-${day}`" :key="`btn-${day}`"
:class="'tag is-' + (data.part === '' ? 'warning' : 'info')" :class="'tag is-' + (data.part === '' ? 'warning' : 'info')"
icon="fa-solid fa-door-open" :icon="['fas', 'fa-door-open']"
:text="day" :text="day.toString()"
@click.left="door_click(day)" @click.left="door_click(Number(day))"
/> />
</div> </div>
</div> </div>
@ -45,72 +45,56 @@
</BulmaDrawer> </BulmaDrawer>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { NumStrDict, objForEach } from "@/lib/api"; import { API } from "@/lib/api";
import { Options, Vue } from "vue-class-component"; import { name_door, objForEach } from "@/lib/helpers";
import type { ImageData, NumStrDict } from "@/lib/model";
import { ref } from "vue";
import MultiModal from "../MultiModal.vue"; import MultiModal, { type HMultiModal } from "../MultiModal.vue";
import BulmaButton from "../bulma/Button.vue"; import BulmaButton from "../bulma/Button.vue";
import BulmaDrawer from "../bulma/Drawer.vue"; import BulmaDrawer from "../bulma/Drawer.vue";
@Options({ const day_data = ref<Record<number, { part: string; image_name: string }>>({});
components: {
BulmaButton,
BulmaDrawer,
MultiModal,
},
})
export default class extends Vue {
public day_data: {
[day: number]: {
part: string;
image_name: string;
};
} = {};
private multi_modal?: MultiModal; let modal: HMultiModal | undefined;
public modal_handle(modal: MultiModal) { function on_modal_handle(handle: HMultiModal): void {
this.multi_modal = modal; modal = handle;
} }
public on_open(ready: () => void, fail: () => void): void { async function on_open(): Promise<void> {
Promise.all([ const [day_parts, day_image_names] = await Promise.all([
this.$advent22.api_get<NumStrDict>("admin/day_parts"), API.request<NumStrDict>("admin/day_parts"),
this.$advent22.api_get<NumStrDict>("admin/day_image_names"), 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) => { const _ensure_day_in_data = (day: number) => {
_ensure_day_in_data(day); if (!(day in day_data.value)) {
this.day_data[day].part = part; day_data.value[day] = { part: "", image_name: "" };
}); }
};
objForEach(day_image_names, (day, image_name) => { objForEach(day_parts, (day, part) => {
_ensure_day_in_data(day); _ensure_day_in_data(day);
this.day_data[day].image_name = image_name; day_data.value[day].part = part;
}); });
ready(); objForEach(day_image_names, (day, image_name) => {
}) _ensure_day_in_data(day);
.catch(fail); day_data.value[day].image_name = image_name;
} });
}
public door_click(day: number) { async function door_click(day: number): Promise<void> {
if (this.multi_modal === undefined) return; if (modal === undefined) return;
this.multi_modal.show_progress(); modal.show_loading();
this.$advent22 try {
.api_get_blob(`user/image_${day}`) const day_image = await API.request<ImageData>(`user/image_${day}`);
.then((image_src) => modal.show_image(day_image.data_url, name_door(day));
this.multi_modal!.show_image(image_src, this.$advent22.name_door(day)), } catch {
) modal.hide();
.catch(() => this.multi_modal!.hide());
} }
} }
</script> </script>

View file

@ -1,5 +1,5 @@
<template> <template>
<BulmaDrawer header="Konfiguration" @open="on_open" refreshable> <BulmaDrawer header="Konfiguration" :opening="on_open" refreshable>
<div class="card-content"> <div class="card-content">
<div class="columns"> <div class="columns">
<div class="column is-one-third"> <div class="column is-one-third">
@ -139,12 +139,15 @@
<dt>Zugangsdaten</dt> <dt>Zugangsdaten</dt>
<dd class="is-family-monospace"> <dd class="is-family-monospace">
<BulmaSecret @load="load_dav_credentials"> <BulmaSecret
@show="load_credentials(creds.dav, 'admin/credentials/dav')"
@hide="clear_credentials(creds.dav)"
>
<span class="tag is-danger">user</span> <span class="tag is-danger">user</span>
{{ dav_credentials[0] }} {{ creds.dav.username }}
<br /> <br />
<span class="tag is-danger">pass</span> <span class="tag is-danger">pass</span>
{{ dav_credentials[1] }} {{ creds.dav.password }}
</BulmaSecret> </BulmaSecret>
</dd> </dd>
@ -167,12 +170,15 @@
<dt>UI-Admin</dt> <dt>UI-Admin</dt>
<dd class="is-family-monospace"> <dd class="is-family-monospace">
<BulmaSecret @load="load_ui_credentials"> <BulmaSecret
@show="load_credentials(creds.ui, 'admin/credentials/ui')"
@hide="clear_credentials(creds.ui)"
>
<span class="tag is-danger">user</span> <span class="tag is-danger">user</span>
{{ ui_credentials[0] }} {{ creds.ui.username }}
<br /> <br />
<span class="tag is-danger">pass</span> <span class="tag is-danger">pass</span>
{{ ui_credentials[1] }} {{ creds.ui.password }}
</BulmaSecret> </BulmaSecret>
</dd> </dd>
</dl> </dl>
@ -183,106 +189,108 @@
</BulmaDrawer> </BulmaDrawer>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { AdminConfigModel, Credentials, DoorSaved } from "@/lib/api"; import { API } from "@/lib/api";
import { advent22Store } from "@/plugins/store"; import type { AdminConfigModel, Credentials, DoorSaved } from "@/lib/model";
import { advent22Store } from "@/lib/store";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { Options, Vue } from "vue-class-component"; import { ref } from "vue";
import BulmaDrawer from "../bulma/Drawer.vue"; import BulmaDrawer from "../bulma/Drawer.vue";
import BulmaSecret from "../bulma/Secret.vue"; import BulmaSecret from "../bulma/Secret.vue";
import CountDown from "../CountDown.vue"; import CountDown from "../CountDown.vue";
@Options({ const store = advent22Store();
components: {
BulmaDrawer, const admin_config_model = ref<AdminConfigModel>({
BulmaSecret, solution: {
CountDown, value: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
whitespace: "KEEP",
special_chars: "KEEP",
case: "KEEP",
clean: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
}, },
}) puzzle: {
export default class extends Vue { first: "2023-12-01",
public readonly store = advent22Store(); 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 admin_config_model: AdminConfigModel = { const doors = ref<DoorSaved[]>([]);
solution: { const creds = ref<Record<string, Credentials>>({
value: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", dav: {
whitespace: "KEEP", username: "",
special_chars: "KEEP", password: "",
case: "KEEP", },
clean: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", ui: {
}, username: "",
puzzle: { password: "",
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 { function fmt_puzzle_date(name: keyof AdminConfigModel["puzzle"]): string {
const iso_date = this.admin_config_model.puzzle[name]; const iso_date = admin_config_model.value.puzzle[name];
if (!(typeof iso_date == "string")) return "-"; if (!(typeof iso_date === "string")) return "-";
return DateTime.fromISO(iso_date).toLocaleString(DateTime.DATE_SHORT); return DateTime.fromISO(iso_date).toLocaleString(DateTime.DATE_SHORT);
} }
public on_open(ready: () => void, fail: () => void): void { async function on_open(): Promise<void> {
Promise.all([ const [store_update, new_admin_config_model, new_doors] = await Promise.all([
this.store.update(), store.update(),
this.$advent22.api_get<AdminConfigModel>("admin/config_model"), API.request<AdminConfigModel>("admin/config_model"),
this.$advent22.api_get<DoorSaved[]>("admin/doors"), API.request<DoorSaved[]>("admin/doors"),
]) ]);
.then(([store_update, admin_config_model, doors]) => {
store_update; // discard value
this.admin_config_model = admin_config_model; void store_update; // discard value
this.doors = doors; admin_config_model.value = new_admin_config_model;
doors.value = new_doors;
ready(); clear_credentials(creds.value.dav);
}) clear_credentials(creds.value.ui);
.catch(fail); }
}
public load_dav_credentials(): void { async function load_credentials(
this.$advent22 creds: Credentials,
.api_get<Credentials>("admin/dav_credentials") endpoint: string,
.then((creds) => (this.dav_credentials = creds)) ): Promise<void> {
.catch(() => {}); try {
} const new_creds = await API.request<Credentials>(endpoint);
public load_ui_credentials(): void { creds.username = new_creds.username;
this.$advent22 creds.password = new_creds.password;
.api_get<Credentials>("admin/ui_credentials") } catch {}
.then((creds) => (this.ui_credentials = creds)) }
.catch(() => {});
} function clear_credentials(creds: Credentials): void {
creds.username = "";
creds.password = "";
} }
</script> </script>

View file

@ -1,11 +1,11 @@
<template> <template>
<BulmaDrawer header="Türchen bearbeiten" @open="on_open"> <BulmaDrawer header="Türchen bearbeiten" :opening="load_doors">
<nav class="level is-mobile mb-0" style="overflow-x: auto"> <nav class="level is-mobile mb-0" style="overflow-x: auto">
<BulmaButton <BulmaButton
:disabled="current_step === 0" :disabled="current_step === 0"
class="level-item is-link" class="level-item is-link"
@click="current_step--" @click="current_step--"
icon="fa-solid fa-backward" :icon="['fas', 'fa-backward']"
/> />
<BulmaBreadcrumbs <BulmaBreadcrumbs
@ -18,7 +18,7 @@
:disabled="current_step === 2" :disabled="current_step === 2"
class="level-item is-link" class="level-item is-link"
@click="current_step++" @click="current_step++"
icon="fa-solid fa-forward" :icon="['fas', 'fa-forward']"
/> />
</nav> </nav>
@ -37,8 +37,8 @@
</div> </div>
</div> </div>
<DoorPlacer v-if="current_step === 0" :doors="doors" /> <DoorPlacer v-if="current_step === 0" v-model="doors" />
<DoorChooser v-if="current_step === 1" :doors="doors" /> <DoorChooser v-if="current_step === 1" v-model="doors" />
<div v-if="current_step === 2" class="card-content"> <div v-if="current_step === 2" class="card-content">
<Calendar :doors="doors" /> <Calendar :doors="doors" />
</div> </div>
@ -47,20 +47,20 @@
<BulmaButton <BulmaButton
class="card-footer-item is-danger" class="card-footer-item is-danger"
@click="on_download" @click="on_download"
icon="fa-solid fa-cloud-arrow-down" :icon="['fas', 'fa-cloud-arrow-down']"
:busy="loading_doors" :busy="loading_doors"
text="Laden" text="Laden"
/> />
<BulmaButton <BulmaButton
class="card-footer-item is-warning" class="card-footer-item is-warning"
@click="on_discard" @click="on_discard"
icon="fa-solid fa-trash" :icon="['fas', 'fa-trash']"
text="Löschen" text="Löschen"
/> />
<BulmaButton <BulmaButton
class="card-footer-item is-success" class="card-footer-item is-success"
@click="on_upload" @click="on_upload"
icon="fa-solid fa-cloud-arrow-up" :icon="['fas', 'fa-cloud-arrow-up']"
:busy="saving_doors" :busy="saving_doors"
text="Speichern" text="Speichern"
/> />
@ -68,127 +68,106 @@
</BulmaDrawer> </BulmaDrawer>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { DoorSaved } from "@/lib/api"; import { API } from "@/lib/api";
import { Door } from "@/lib/door"; import { APIError } from "@/lib/api_error";
import { advent22Store } from "@/plugins/store"; import type { DoorSaved } from "@/lib/model";
import { Options, Vue } from "vue-class-component"; import { Door } from "@/lib/rects/door";
import { toast } from "bulma-toast"; import { toast } from "bulma-toast";
import { ref } from "vue";
import type { BCStep } from "../bulma/Breadcrumbs.vue";
import Calendar from "../Calendar.vue"; import Calendar from "../Calendar.vue";
import BulmaBreadcrumbs, { Step } from "../bulma/Breadcrumbs.vue"; import BulmaBreadcrumbs from "../bulma/Breadcrumbs.vue";
import BulmaButton from "../bulma/Button.vue"; import BulmaButton from "../bulma/Button.vue";
import BulmaDrawer from "../bulma/Drawer.vue"; import BulmaDrawer from "../bulma/Drawer.vue";
import DoorChooser from "../editor/DoorChooser.vue"; import DoorChooser from "../editor/DoorChooser.vue";
import DoorPlacer from "../editor/DoorPlacer.vue"; import DoorPlacer from "../editor/DoorPlacer.vue";
@Options({ const steps: BCStep[] = [
components: { { label: "Platzieren", icon: ["fas", "fa-crosshairs"] },
BulmaBreadcrumbs, { label: "Ordnen", icon: ["fas", "fa-list-ol"] },
BulmaButton, { label: "Vorschau", icon: ["fas", "fa-magnifying-glass"] },
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; const doors = ref<Door[]>([]);
public saving_doors = false; const current_step = ref(0);
const loading_doors = ref(false);
const saving_doors = ref(false);
private load_doors(): Promise<void> { async function load_doors(): Promise<void> {
return new Promise<void>((resolve, reject) => { try {
this.$advent22 const data = await API.request<DoorSaved[]>("admin/doors");
.api_get<DoorSaved[]>("admin/doors")
.then((data) => {
this.doors.length = 0;
for (const value of data) { doors.value.length = 0;
this.doors.push(Door.load(value)); for (const value of data) {
} doors.value.push(Door.load(value));
}
} catch (error) {
APIError.alert(error);
throw null;
}
}
resolve(); async function save_doors(): Promise<void> {
}) try {
.catch((error) => { const data: DoorSaved[] = [];
this.store.alert_user_error(error);
reject(); for (const door of doors.value) {
}); data.push(door.save());
}
await API.request<void>({
endpoint: "admin/doors",
method: "PUT",
data: data,
}); });
} catch (error) {
APIError.alert(error);
throw null;
} }
}
private save_doors(): Promise<void> { async function on_download(): Promise<void> {
return new Promise<void>((resolve, reject) => { if (confirm("Aktuelle Änderungen verwerfen und Status vom Server laden?")) {
const data: DoorSaved[] = []; loading_doors.value = true;
for (const door of this.doors) { try {
data.push(door.save()); load_doors();
}
this.$advent22 toast({
.api_put("admin/doors", data) message: "Erfolgreich!",
.then(resolve) type: "is-success",
.catch((error) => { duration: 2e3,
this.store.alert_user_error(error); });
reject(); } finally {
}); loading_doors.value = false;
});
}
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() { function on_discard(): void {
if (confirm("Alle Türchen löschen? (nur lokal)")) { if (confirm("Alle Türchen löschen? (nur lokal)")) {
// empty `doors` array // empty `doors` array
this.doors.length = 0; doors.value.length = 0;
}
} }
}
public on_upload() { async function on_upload(): Promise<void> {
if (confirm("Aktuelle Änderungen an den Server schicken?")) { if (confirm("Aktuelle Änderungen an den Server schicken?")) {
this.saving_doors = true; saving_doors.value = true;
this.save_doors() try {
.then(() => { save_doors();
this.load_doors() load_doors();
.then(() =>
toast({ toast({
message: "Erfolgreich!", message: "Erfolgreich!",
type: "is-success", type: "is-success",
duration: 2e3, duration: 2e3,
}), });
) } finally {
.catch(() => {}) saving_doors.value = false;
.finally(() => (this.saving_doors = false));
})
.catch(() => (this.saving_doors = false));
} }
} }
} }

View file

@ -1,15 +1,16 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template> <template>
<nav class="breadcrumb has-succeeds-separator"> <nav class="breadcrumb has-succeeds-separator">
<ul> <ul>
<li <li
v-for="(step, index) in steps" v-for="(step, index) in steps"
:key="`step-${index}`" :key="index"
:class="modelValue === index ? 'is-active' : ''" :class="model === index ? 'is-active' : ''"
@click.left="change_step(index)" @click.left="model = index"
> >
<a> <a :class="model === index ? 'has-text-primary' : ''">
<span class="icon is-small"> <span class="icon is-small">
<font-awesome-icon :icon="step.icon" /> <FontAwesomeIcon :icon="step.icon" />
</span> </span>
<span>{{ step.label }}</span> <span>{{ step.label }}</span>
</a> </a>
@ -18,31 +19,15 @@
</nav> </nav>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Options, Vue } from "vue-class-component"; export interface BCStep {
export interface Step {
label: string; label: string;
icon: string; icon: string | string[];
} }
@Options({ const model = defineModel<number>({ required: true });
props: {
steps: Array,
modelValue: Number,
},
emits: ["update:modelValue"],
})
export default class extends Vue {
public steps!: Step[];
public modelValue!: number;
public change_step(next_step: number) { defineProps<{
if (next_step === this.modelValue) { steps: BCStep[];
return; }>();
}
this.$emit("update:modelValue", next_step);
}
}
</script> </script>

View file

@ -1,45 +1,28 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template> <template>
<button class="button"> <button class="button">
<slot v-if="text === undefined" name="default"> <slot name="default">
<font-awesome-icon
v-if="icon !== undefined"
:icon="icon"
:beat-fade="busy"
/>
</slot>
<template v-else>
<span v-if="icon !== undefined" class="icon"> <span v-if="icon !== undefined" class="icon">
<slot name="default"> <FontAwesomeIcon
<font-awesome-icon :icon="icon" :beat-fade="busy" /> v-if="icon !== undefined"
</slot> :icon="icon"
:beat-fade="busy"
/>
</span> </span>
<span>{{ text }}</span> </slot>
</template> <span v-if="text !== undefined">{{ text }}</span>
</button> </button>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Options, Vue } from "vue-class-component"; withDefaults(
defineProps<{
@Options({ icon?: string | string[];
props: { text?: string;
icon: { busy?: boolean;
type: String, }>(),
required: false, {
}, busy: false,
text: {
type: String,
required: false,
},
busy: {
type: Boolean,
default: false,
},
}, },
}) );
export default class extends Vue {
public icon?: string;
public text?: string;
public busy!: boolean;
}
</script> </script>

View file

@ -1,99 +1,87 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template> <template>
<div class="card"> <div class="card">
<header class="card-header is-unselectable" style="cursor: pointer"> <header class="card-header is-unselectable" style="cursor: pointer">
<p class="card-header-title" @click="toggle">{{ header }}</p> <p class="card-header-title" @click="toggle">{{ header }}</p>
<p v-if="refreshable" class="card-header-icon px-0"> <p v-if="refreshable && is_open" class="card-header-icon px-0">
<BulmaButton class="tag icon is-primary" @click="refresh"> <BulmaButton class="is-small is-primary" @click="load">
<font-awesome-icon <FontAwesomeIcon
icon="fa-solid fa-arrows-rotate" :icon="['fas', 'arrows-rotate']"
:spin="is_open && loading" :spin="state === 'loading'"
/> />
</BulmaButton> </BulmaButton>
</p> </p>
<button class="card-header-icon" @click="toggle"> <button class="card-header-icon" @click="toggle">
<span class="icon"> <span class="icon">
<font-awesome-icon <FontAwesomeIcon
:icon="'fa-solid fa-angle-' + (is_open ? 'down' : 'right')" :icon="['fas', is_open ? 'angle-down' : 'angle-right']"
/> />
</span> </span>
</button> </button>
</header> </header>
<template v-if="is_open"> <slot v-if="state === 'loading'" name="loading">
<div v-if="loading" class="card-content"> <div class="card-content">
<progress class="progress is-primary" max="100" /> <progress class="progress is-primary" />
</div> </div>
<div </slot>
v-else-if="failed"
class="card-content has-text-danger has-text-centered" <slot v-else-if="state === 'err'" name="error">
> <div class="card-content has-text-danger has-text-centered">
<span class="icon is-large"> <span class="icon is-large">
<font-awesome-icon icon="fa-solid fa-ban" size="3x" /> <FontAwesomeIcon :icon="['fas', 'ban']" size="3x" />
</span> </span>
</div> </div>
<slot v-else name="default" /> </slot>
</template>
<slot v-else-if="state === 'ok'" name="default" />
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Options, Vue } from "vue-class-component"; import { computed, ref } from "vue";
import BulmaButton from "./Button.vue"; import BulmaButton from "./Button.vue";
enum DrawerState { const props = withDefaults(
Loading, defineProps<{
Ready, header: string;
Failed, opening?: () => Promise<void>;
refreshable?: boolean;
}>(),
{ opening: async () => {}, refreshable: false },
);
const state = ref<"closed" | "loading" | "ok" | "err">("closed");
const is_open = computed(() => state.value !== "closed");
async function toggle(): Promise<void> {
switch (state.value) {
case "closed":
// start opening when closed
await load();
break;
case "loading":
// don't toggle when loading
break;
default:
state.value = "closed";
break;
}
} }
@Options({ async function load(): Promise<void> {
components: { state.value = "loading";
BulmaButton,
},
props: {
header: String,
refreshable: {
type: Boolean,
default: false,
},
},
emits: ["open"],
})
export default class extends Vue {
public header!: string;
public refreshable!: boolean;
public is_open = false; try {
public state = DrawerState.Loading; await props.opening();
state.value = "ok";
public toggle() { } catch {
this.is_open = !this.is_open; state.value = "err";
if (this.is_open) {
this.state = DrawerState.Loading;
this.$emit(
"open",
() => (this.state = DrawerState.Ready),
() => (this.state = DrawerState.Failed),
);
}
}
public refresh() {
this.is_open = false;
this.toggle();
}
public get loading(): boolean {
return this.state === DrawerState.Loading;
}
public get failed(): boolean {
return this.state === DrawerState.Failed;
} }
} }
</script> </script>

View file

@ -1,68 +1,52 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template> <template>
<slot v-if="show" name="default" /> <slot v-if="state === 'visible'" name="default" />
<span v-else>***</span> <span v-else>***</span>
<BulmaButton <BulmaButton
:class="`tag icon is-${button_class} ml-2`" :class="`is-small is-${record.color} ml-2`"
:icon="`fa-solid fa-${button_icon}`" :icon="['fas', record.icon]"
:busy="busy" :busy="state === 'pending'"
@click="on_click" @click="on_click"
/> />
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Options, Vue } from "vue-class-component"; import { computed, ref } from "vue";
import BulmaButton from "./Button.vue"; import BulmaButton from "./Button.vue";
enum ClickState { const emit = defineEmits<{
Green = 0, (event: "show"): void;
Yellow = 1, (event: "hide"): void;
Red = 2, }>();
}
@Options({ type State = "hidden" | "pending" | "visible";
components: { const state = ref<State>("hidden");
BulmaButton,
},
emits: ["load"],
})
export default class extends Vue {
public state = ClickState.Green;
public on_click(): void { const state_map: Record<State, { color: string; icon: string; next: State }> = {
this.state++; hidden: { color: "primary", icon: "eye-slash", next: "pending" },
this.state %= 3; pending: { color: "warning", icon: "eye-slash", next: "visible" },
visible: { color: "danger", icon: "eye", next: "hidden" },
} as const;
const record = computed(() => state_map[state.value] ?? state_map.hidden);
if (this.state === ClickState.Red) { let pending_timeout: number | undefined;
this.$emit("load");
} function on_click(): void {
state.value = record.value.next;
if (state.value === "hidden") {
emit("hide");
} }
public get show(): boolean { if (state.value === "pending") {
return this.state === ClickState.Red; pending_timeout = window.setTimeout(() => (state.value = "hidden"), 2500);
} else {
window.clearTimeout(pending_timeout);
} }
public get busy(): boolean { if (state.value === "visible") {
return this.state === ClickState.Yellow; emit("show");
}
public get button_class(): string {
switch (this.state) {
case ClickState.Red:
return "danger";
case ClickState.Yellow:
return "warning";
default:
return "primary";
}
}
public get button_icon(): string {
if (this.state === ClickState.Red) {
return "eye-slash";
} else {
return "eye";
}
} }
} }
</script> </script>

View file

@ -1,3 +1,4 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template> <template>
<div style="display: none"> <div style="display: none">
<div v-bind="$attrs" ref="message"> <div v-bind="$attrs" ref="message">
@ -6,38 +7,41 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import * as bulmaToast from "bulma-toast"; import { type Options as ToastOptions, toast } from "bulma-toast";
import { Options, Vue } from "vue-class-component"; import { onMounted, useTemplateRef } from "vue";
@Options({ export type HBulmaToast = {
emits: ["handle"], show(options: ToastOptions): void;
}) hide(): void;
export default class extends Vue { };
public created(): void {
this.$emit("handle", this);
}
public show(options: bulmaToast.Options = {}) { const emit = defineEmits<{
if (!(this.$refs.message instanceof HTMLElement)) return; (event: "handle", handle: HBulmaToast): void;
}>();
bulmaToast.toast({ const message_div = useTemplateRef("message");
...options,
single: true,
message: this.$refs.message,
});
}
public hide() { onMounted(() =>
if (!(this.$refs.message instanceof HTMLElement)) return; emit("handle", {
show(options: ToastOptions = {}): void {
if (message_div.value === null) return;
const toast_div = this.$refs.message.parentElement; toast({
if (!(toast_div instanceof HTMLDivElement)) return; ...options,
single: true,
message: message_div.value,
});
},
hide(): void {
// using "toast" detaches "message" from the invisible "div"
// => toast_div is not part of this component!
const toast_div = message_div.value?.parentElement;
const delete_button = toast_div?.querySelector("button.delete");
if (!(delete_button instanceof HTMLButtonElement)) return;
const dbutton = toast_div.querySelector("button.delete"); delete_button.click();
if (!(dbutton instanceof HTMLButtonElement)) return; },
}),
dbutton.click(); );
}
}
</script> </script>

View file

@ -13,29 +13,22 @@
</SVGRect> </SVGRect>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Door } from "@/lib/door"; import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/plugins/store"; import { advent22Store } from "@/lib/store";
import { Options, Vue } from "vue-class-component";
import type { VueLike } from "@/lib/helpers";
import SVGRect from "./SVGRect.vue"; import SVGRect from "./SVGRect.vue";
@Options({ const store = advent22Store();
components: {
SVGRect,
},
props: {
door: Door,
force_visible: {
type: Boolean,
default: false,
},
},
})
export default class extends Vue {
public readonly store = advent22Store();
public door!: Door; withDefaults(
public force_visible!: boolean; defineProps<{
} door: VueLike<Door>;
force_visible?: boolean;
}>(),
{
force_visible: false,
},
);
</script> </script>

View file

@ -1,26 +1,31 @@
<template> <template>
<foreignObject <foreignObject
:x="Math.round(store.calendar_aspect_ratio * rectangle.left)" :x="Math.round(aspect_ratio * rectangle.left)"
:y="rectangle.top" :y="rectangle.top"
:width="Math.round(store.calendar_aspect_ratio * rectangle.width)" :width="Math.round(aspect_ratio * rectangle.width)"
:height="rectangle.height" :height="rectangle.height"
:style="`transform: scaleX(${1 / store.calendar_aspect_ratio})`" :style="`transform: scaleX(${1 / aspect_ratio})`"
> >
<div <div
xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml"
:class="`px-2 is-flex is-align-items-center is-justify-content-center is-size-2 has-text-weight-bold ${extra_classes}`" :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" style="height: inherit"
:title="title" v-bind="$attrs"
> >
<slot name="default" /> <slot name="default" />
</div> </div>
</foreignObject> </foreignObject>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Rectangle } from "@/lib/rectangle"; import { type VueLike, unwrap_loading } from "@/lib/helpers";
import { advent22Store } from "@/plugins/store"; import { Rectangle } from "@/lib/rects/rectangle";
import { Options, Vue } from "vue-class-component"; import { advent22Store } from "@/lib/store";
import { computed } from "vue";
const store = advent22Store();
type BulmaVariant = type BulmaVariant =
| "primary" | "primary"
@ -30,40 +35,28 @@ type BulmaVariant =
| "warning" | "warning"
| "danger"; | "danger";
@Options({ withDefaults(
props: { defineProps<{
variant: String, variant: BulmaVariant;
visible: { visible?: boolean;
type: Boolean, rectangle: VueLike<Rectangle>;
default: false, }>(),
}, {
rectangle: Rectangle, visible: true,
title: {
type: String,
required: false,
},
}, },
}) );
export default class extends Vue {
public readonly store = advent22Store();
private variant!: BulmaVariant; const aspect_ratio = computed(() => {
private visible!: boolean; try {
public rectangle!: Rectangle; return unwrap_loading(store.background_image).aspect_ratio;
public title?: string; } catch {
return 1;
public get extra_classes(): string {
let result = this.variant;
if (this.visible) result += " visible";
return result;
} }
} });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "@/bulma-scheme"; @use "@/bulma-scheme" as scheme;
foreignObject > div { foreignObject > div {
&:not(.visible, :hover):deep() > * { &:not(.visible, :hover):deep() > * {
@ -75,7 +68,7 @@ foreignObject > div {
border-width: 2px; border-width: 2px;
border-style: solid; border-style: solid;
@each $name, $color in $advent22-colors { @each $name, $color in scheme.$colors {
&.#{$name} { &.#{$name} {
background-color: rgba($color, 0.3); background-color: rgba($color, 0.3);
border-color: rgba($color, 0.9); border-color: rgba($color, 0.9);

View file

@ -15,10 +15,8 @@
</svg> </svg>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Vector2D } from "@/lib/vector2d"; import { Vector2D } from "@/lib/rects/vector2d";
import { advent22Store } from "@/plugins/store";
import { Options, Vue } from "vue-class-component";
function get_event_thous(event: MouseEvent): Vector2D { function get_event_thous(event: MouseEvent): Vector2D {
if (!(event.currentTarget instanceof SVGSVGElement)) { if (!(event.currentTarget instanceof SVGSVGElement)) {
@ -31,45 +29,23 @@ function get_event_thous(event: MouseEvent): Vector2D {
); );
} }
function mouse_event_validator(event: object, point: object): boolean { type TCEventType = "mousedown" | "mousemove" | "mouseup" | "click" | "dblclick";
if (!(event instanceof MouseEvent)) {
console.warn(event, "is not a MouseEvent!");
return false;
}
if (!(point instanceof Vector2D)) { const is_tceventtype = (t: unknown): t is TCEventType =>
console.warn(point, "is not a Vector2D!"); t === "mousedown" ||
return false; t === "mousemove" ||
} t === "mouseup" ||
t === "click" ||
t === "dblclick";
return true; const emit = defineEmits<{
} (event: TCEventType, e: MouseEvent, point: Vector2D): void;
}>();
@Options({ function transform_mouse_event(event: MouseEvent): void {
emits: { if (!is_tceventtype(event.type)) return;
mousedown: mouse_event_validator,
mouseup: mouse_event_validator,
mousemove: mouse_event_validator,
click: mouse_event_validator,
dblclick: mouse_event_validator,
},
})
export default class extends Vue {
public readonly store = advent22Store();
public mounted(): void { emit(event.type, event, get_event_thous(event));
new ResizeObserver(([first, ...rest]) => {
if (rest.length > 0)
console.warn(`Unexpected ${rest.length} extra entries!`);
this.store.set_calendar_aspect_ratio(first.contentRect);
}).observe(this.$el);
}
public transform_mouse_event(event: MouseEvent) {
const point = get_event_thous(event);
this.$emit(event.type, event, point);
}
} }
</script> </script>

View file

@ -9,7 +9,7 @@
@dblclick.left="remove_rect" @dblclick.left="remove_rect"
> >
<CalendarDoor <CalendarDoor
v-for="(door, index) in doors" v-for="(door, index) in model"
:key="`door-${index}`" :key="`door-${index}`"
:door="door" :door="door"
force_visible force_visible
@ -17,132 +17,94 @@
<SVGRect <SVGRect
v-if="preview_visible" v-if="preview_visible"
variant="success" variant="success"
:rectangle="preview_rect" :rectangle="preview"
visible visible
/> />
</ThouCanvas> </ThouCanvas>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Door } from "@/lib/door"; import { Door } from "@/lib/rects/door";
import { Rectangle } from "@/lib/rectangle"; import { Rectangle } from "@/lib/rects/rectangle";
import { Vector2D } from "@/lib/vector2d"; import { Vector2D } from "@/lib/rects/vector2d";
import { Options, Vue } from "vue-class-component"; import { computed, ref } from "vue";
import type { VueLike } from "@/lib/helpers";
import CalendarDoor from "../calendar/CalendarDoor.vue"; import CalendarDoor from "../calendar/CalendarDoor.vue";
import SVGRect from "../calendar/SVGRect.vue"; import SVGRect from "../calendar/SVGRect.vue";
import ThouCanvas from "../calendar/ThouCanvas.vue"; import ThouCanvas from "../calendar/ThouCanvas.vue";
enum CanvasState { type CanvasState =
Idle, | { kind: "idle" }
Drawing, | { kind: "drawing" }
Dragging, | { kind: "dragging"; door: VueLike<Door>; origin: Vector2D };
const model = defineModel<VueLike<Door>[]>({ required: true });
const MIN_RECT_AREA = 300;
const state = ref<CanvasState>({ kind: "idle" });
const preview = ref(new Rectangle());
const preview_visible = computed(() => state.value.kind !== "idle");
function pop_door(point: Vector2D): VueLike<Door> | undefined {
const idx = model.value.findIndex((rect) => rect.position.contains(point));
if (idx === -1) return;
return model.value.splice(idx, 1)[0];
} }
@Options({ function draw_start(event: MouseEvent, point: Vector2D): void {
components: { if (preview_visible.value) return;
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 { preview.value = new Rectangle(point, point);
return this.state !== CanvasState.Idle; state.value = { kind: "drawing" };
}
function draw_finish(): void {
if (state.value.kind !== "drawing") return;
if (preview.value.area >= MIN_RECT_AREA) {
model.value.push(new Door(preview.value));
} }
private pop_door(point: Vector2D): Door | undefined { state.value = { kind: "idle" };
const idx = this.doors.findIndex((rect) => rect.position.contains(point)); }
if (idx === -1) { function drag_start(event: MouseEvent, point: Vector2D): void {
return; if (preview_visible.value) return;
}
return this.doors.splice(idx, 1)[0]; const drag_door = pop_door(point);
if (drag_door === undefined) return;
preview.value = drag_door.position;
state.value = { kind: "dragging", door: drag_door, origin: point };
}
function drag_finish(): void {
if (state.value.kind !== "dragging") return;
model.value.push(new Door(preview.value, state.value.door.day));
state.value = { kind: "idle" };
}
function on_mousemove(event: MouseEvent, point: Vector2D): void {
if (state.value.kind === "drawing") {
preview.value = preview.value.update(undefined, point);
} else if (state.value.kind === "dragging") {
const movement = point.minus(state.value.origin);
preview.value = state.value.door.position.move(movement);
} }
}
public draw_start(event: MouseEvent, point: Vector2D) { function remove_rect(event: MouseEvent, point: Vector2D): void {
if (this.preview_visible) { if (preview_visible.value) return;
return;
}
this.state = CanvasState.Drawing; pop_door(point);
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> </script>

View file

@ -11,37 +11,26 @@
</ul> </ul>
</div> </div>
<figure class="image is-unselectable"> <figure class="image is-unselectable">
<img :src="store.calendar_background_image" /> <img :src="unwrap_loading(store.background_image).data_url" />
<ThouCanvas> <ThouCanvas>
<PreviewDoor <PreviewDoor
v-for="(door, index) in doors" v-for="(_, index) in model"
:key="`door-${index}`" :key="`door-${index}`"
:door="door" v-model="model[index]"
/> />
</ThouCanvas> </ThouCanvas>
</figure> </figure>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Door } from "@/lib/door"; import { type VueLike, unwrap_loading } from "@/lib/helpers";
import { advent22Store } from "@/plugins/store"; import { Door } from "@/lib/rects/door";
import { Options, Vue } from "vue-class-component"; import { advent22Store } from "@/lib/store";
import ThouCanvas from "../calendar/ThouCanvas.vue"; import ThouCanvas from "../calendar/ThouCanvas.vue";
import PreviewDoor from "./PreviewDoor.vue"; import PreviewDoor from "./PreviewDoor.vue";
@Options({ const model = defineModel<VueLike<Door>[]>({ required: true });
components: { const store = advent22Store();
ThouCanvas,
PreviewDoor,
},
props: {
doors: Array,
},
})
export default class extends Vue {
public doors!: Door[];
public readonly store = advent22Store();
}
</script> </script>

View file

@ -9,29 +9,19 @@
</ul> </ul>
</div> </div>
<figure class="image is-unselectable"> <figure class="image is-unselectable">
<img :src="store.calendar_background_image" /> <img :src="unwrap_loading(store.background_image).data_url" />
<DoorCanvas :doors="doors" /> <DoorCanvas v-model="model" />
</figure> </figure>
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { Door } from "@/lib/door"; import { type VueLike, unwrap_loading } from "@/lib/helpers";
import { advent22Store } from "@/plugins/store"; import { Door } from "@/lib/rects/door";
import { Options, Vue } from "vue-class-component"; import { advent22Store } from "@/lib/store";
import DoorCanvas from "./DoorCanvas.vue"; import DoorCanvas from "./DoorCanvas.vue";
@Options({ const model = defineModel<VueLike<Door>[]>({ required: true });
components: { const store = advent22Store();
DoorCanvas,
},
props: {
doors: Array,
},
})
export default class extends Vue {
public doors!: Door[];
public readonly store = advent22Store();
}
</script> </script>

View file

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

View file

@ -1,10 +0,0 @@
import { Advent22 } from "@/plugins/advent22";
declare module "@vue/runtime-core" {
// bind to `this` keyword
interface ComponentCustomProperties {
$advent22: Advent22;
}
}
export {};

View file

@ -1,71 +1,92 @@
export interface AdminConfigModel { import type {
solution: { AxiosBasicCredentials,
value: string; AxiosRequestConfig,
whitespace: string; Method,
special_chars: string; RawAxiosRequestHeaders,
case: string; } from "axios";
clean: string; import axios from "axios";
}; import { APIError } from "./api_error";
puzzle: {
first: string; interface Params {
next: string | null; endpoint: string;
last: string; method?: Method;
end: string; data?: unknown;
seed: string; headers?: RawAxiosRequestHeaders;
extra_days: number[]; config?: AxiosRequestConfig;
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 { export class API {
title: string; private static get api_baseurl(): string {
subtitle: string; // in production mode, return "proto://hostname/api"
content: string; if (process.env.NODE_ENV === "production") {
footer: string; 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);
}
export interface NumStrDict { // in development mode, return "proto://hostname:8000/api"
[key: number]: string; return `${window.location.protocol}//${window.location.hostname}:8000/api`;
} }
export interface DoorSaved { private static readonly axios = axios.create({
day: number; timeout: 10e3,
x1: number; baseURL: this.api_baseurl,
y1: number; });
x2: number;
y2: number;
}
export type Credentials = [username: string, password: string]; private static readonly creds_key = "advent22/credentials";
export function objForEach<T>( public static set creds(value: AxiosBasicCredentials | null) {
obj: T, if (value === null) {
f: (k: keyof T, v: T[keyof T]) => void, localStorage.removeItem(this.creds_key);
): void { } else {
for (const k in obj) { localStorage.setItem(this.creds_key, JSON.stringify(value));
if (Object.prototype.hasOwnProperty.call(obj, k)) { }
f(k, obj[k]); }
public static get creds(): AxiosBasicCredentials {
const stored_auth = JSON.parse(localStorage.getItem(this.creds_key) ?? "");
if (
stored_auth !== null &&
Object.hasOwn(stored_auth, "username") &&
Object.hasOwn(stored_auth, "password")
) {
return stored_auth;
}
return { username: "", password: "" };
}
private static get_axios_config({
endpoint,
method = "GET",
data,
headers = {},
config = {},
}: Params): AxiosRequestConfig {
return {
url: endpoint,
method: method,
data: data,
auth: this.creds,
headers: headers,
...config,
};
}
public static async request<T = string>(p: Params): Promise<T>;
public static async request<T = string>(p: string): Promise<T>;
public static async request<T = string>(p: Params | string): Promise<T> {
if (typeof p === "string") p = { endpoint: p };
try {
const response = await this.axios.request<T>(this.get_axios_config(p));
return response.data;
} catch (reason) {
// eslint-disable-next-line no-console
console.error(`Failed to query ${p.endpoint}: ${reason}`);
throw new APIError(reason, p.endpoint);
} }
} }
} }

75
ui/src/lib/api_error.ts Normal file
View file

@ -0,0 +1,75 @@
import { AxiosError } from "axios";
import { toast } from "bulma-toast";
export class APIError extends Error {
axios_error?: AxiosError;
constructor(reason: unknown, endpoint: string) {
super(endpoint); // sets this.message to the endpoint
Object.setPrototypeOf(this, APIError.prototype);
if (reason instanceof AxiosError) {
this.axios_error = reason;
}
}
public format(): string {
let msg =
"Unbekannter Fehler, bitte wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
let code = "U";
const result = () => `${msg} (Fehlercode: ${code}/${this.message})`;
if (this.axios_error === undefined) return result();
switch (this.axios_error.code) {
case "ECONNABORTED":
// API unerreichbar
msg =
"API antwortet nicht, bitte später wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
code = "D";
break;
case "ERR_NETWORK":
// Netzwerk nicht verbunden
msg = "Sieht aus, als sei deine Netzwerkverbindung gestört.";
code = "N";
break;
default:
if (this.axios_error.response === undefined) return result();
switch (this.axios_error.response.status) {
case 401:
// UNAUTHORIZED
msg = "Netter Versuch :)";
code = "A";
break;
case 422:
// UNPROCESSABLE ENTITY
msg = "Funktion ist kaputt, bitte Admin benachrichtigen!";
code = "I";
break;
default:
// HTTP
code = `H${this.axios_error.response.status}`;
break;
}
break;
}
return result();
}
public alert(): void {
toast({
message: this.format(),
type: "is-danger",
});
}
public static alert(error: unknown): void {
new APIError(error, "").alert();
}
}

View file

@ -1,20 +1,14 @@
import { App, Plugin } from "vue"; /* import font awesome icon component */
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
/* import the fontawesome core */ /* import the fontawesome core */
import { library } from "@fortawesome/fontawesome-svg-core"; import { library } from "@fortawesome/fontawesome-svg-core";
/* import specific icons */ /* import specific icons */
import { fab } from "@fortawesome/free-brands-svg-icons"; // import { fab } from "@fortawesome/free-brands-svg-icons";
import { fas } from "@fortawesome/free-solid-svg-icons"; import { fas } from "@fortawesome/free-solid-svg-icons";
/* add icons to the library */ /* add icons to the library */
library.add(fas, fab); library.add(fas);
/* import font awesome icon component */ export default FontAwesomeIcon;
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
export const FontAwesomePlugin: Plugin = {
install(app: App) {
app.component("font-awesome-icon", FontAwesomeIcon);
},
};

52
ui/src/lib/helpers.ts Normal file
View file

@ -0,0 +1,52 @@
import { nextTick, type UnwrapRef } from "vue";
import { APIError } from "./api_error";
export function objForEach<T>(
obj: T,
f: (k: keyof T, v: T[keyof T]) => void,
): void {
for (const k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k)) {
f(k, obj[k]);
}
}
}
export type VueLike<T> = T | UnwrapRef<T>;
export function unwrap_vuelike<T>(value: VueLike<T>): T {
return value as T;
}
export type Loading<T> = T | "loading" | "error";
export function unwrap_loading<T>(o: Loading<T>): T {
if (o === "loading" || o === "error") throw null;
return o;
}
export function wait_for(condition: () => boolean, action: () => void): void {
const enqueue_action = () => {
if (!condition()) {
nextTick(enqueue_action);
return;
}
action();
};
enqueue_action();
}
export function handle_error(error: unknown): void {
if (error instanceof APIError) {
error.alert();
} else {
// eslint-disable-next-line no-console
console.error(error);
}
}
export function name_door(day: number): string {
return `Türchen ${day}`;
}

70
ui/src/lib/model.ts Normal file
View file

@ -0,0 +1,70 @@
export interface AdminConfigModel {
solution: {
value: string;
whitespace: string;
special_chars: string;
case: string;
clean: string;
};
puzzle: {
first: string;
next: string | null;
last: string;
end: string;
seed: string;
extra_days: number[];
skip_empty: boolean;
};
calendar: {
config_file: string;
background: string;
favicon: string;
};
image: {
size: number;
border: number;
};
fonts: { file: string; size: number }[];
redis: {
host: string;
port: number;
db: number;
protocol: number;
};
webdav: {
url: string;
cache_ttl: number;
config_file: string;
};
}
export interface SiteConfigModel {
title: string;
subtitle: string;
content: string;
footer: string;
}
export interface NumStrDict {
[key: number]: string;
}
export interface DoorSaved {
day: number;
x1: number;
y1: number;
x2: number;
y2: number;
}
export interface ImageData {
width: number;
height: number;
aspect_ratio: number;
data_url: string;
}
export interface Credentials {
username: string;
password: string;
}

View file

@ -1,4 +1,5 @@
import { DoorSaved } from "./api"; import { type VueLike, unwrap_vuelike } from "../helpers";
import type { DoorSaved } from "../model";
import { Rectangle } from "./rectangle"; import { Rectangle } from "./rectangle";
import { Vector2D } from "./vector2d"; import { Vector2D } from "./vector2d";
@ -8,26 +9,27 @@ export class Door {
private _day = Door.MIN_DAY; private _day = Door.MIN_DAY;
public position: Rectangle; public position: Rectangle;
constructor(position: Rectangle); constructor(position: VueLike<Rectangle>);
constructor(position: Rectangle, day: number); constructor(position: VueLike<Rectangle>, day: number);
constructor(position: Rectangle, day = Door.MIN_DAY) { constructor(position: VueLike<Rectangle>, day = Door.MIN_DAY) {
this.day = day; this.day = day;
this.position = position; this.position = unwrap_vuelike(position);
} }
public get day(): number { public get day(): number {
return this._day; return this._day;
} }
public set day(day: unknown) { public set day(value: number | string) {
// integer coercion // integer coercion
const result = Number(day); let day = Number(value);
if (isNaN(result)) { day =
this._day = Door.MIN_DAY; !Number.isNaN(day) && Number.isFinite(day)
} else { ? Math.trunc(day)
this._day = Math.max(Math.floor(result), Door.MIN_DAY); : Door.MIN_DAY;
}
this._day = Math.max(day, Door.MIN_DAY);
} }
public static load(serialized: DoorSaved): Door { public static load(serialized: DoorSaved): Door {

View file

@ -67,7 +67,7 @@ export class Rectangle {
} }
public update(corner_1?: Vector2D, corner_2?: Vector2D): Rectangle { public update(corner_1?: Vector2D, corner_2?: Vector2D): Rectangle {
return new Rectangle(corner_1 || this.corner_1, corner_2 || this.corner_2); return new Rectangle(corner_1 ?? this.corner_1, corner_2 ?? this.corner_2);
} }
public move(vector: Vector2D): Rectangle { public move(vector: Vector2D): Rectangle {

131
ui/src/lib/store.ts Normal file
View file

@ -0,0 +1,131 @@
import { acceptHMRUpdate, defineStore } from "pinia";
import { API } from "./api";
import type { Loading } from "./helpers";
import type { Credentials, DoorSaved, ImageData, SiteConfigModel } from "./model";
import { Door } from "./rects/door";
declare global {
interface Navigator {
readonly msMaxTouchPoints: number;
}
}
type State = {
on_initialized: (() => void)[] | null;
is_touch_device: boolean;
is_admin: boolean;
site_config: SiteConfigModel;
background_image: Loading<ImageData>;
user_doors: Door[];
next_door_target: number | null;
};
export const advent22Store = defineStore("advent22", {
state: (): State => ({
on_initialized: [],
is_touch_device:
window.matchMedia("(any-hover: none)").matches ||
"ontouchstart" in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0,
is_admin: false,
site_config: {
title: document.title,
subtitle: "",
content: "",
footer: "",
},
background_image: "loading",
user_doors: [],
next_door_target: null,
}),
actions: {
async init(): Promise<void> {
await this.update();
if (this.on_initialized !== null) {
for (const callback of this.on_initialized) callback();
}
this.on_initialized = null;
},
async update(): Promise<void> {
try {
const favicon = await API.request<ImageData>("user/favicon");
const link: HTMLLinkElement =
document.querySelector("link[rel*='icon']") ??
document.createElement("link");
link.rel = "shortcut icon";
link.type = "image/x-icon";
link.href = favicon.data_url;
if (link.parentElement === null)
document.getElementsByTagName("head")[0]!.appendChild(link);
} catch { }
try {
const [is_admin, site_config, background_image, user_doors, next_door] =
await Promise.all([
this.update_is_admin(),
API.request<SiteConfigModel>("user/site_config"),
API.request<ImageData>("user/background_image"),
API.request<DoorSaved[]>("user/doors"),
API.request<number | null>("user/next_door"),
]);
void is_admin; // discard value
document.title = site_config.title;
if (site_config.subtitle !== "")
document.title += " " + site_config.subtitle;
this.site_config = site_config;
this.background_image = background_image;
this.user_doors.length = 0;
for (const door_saved of user_doors) {
this.user_doors.push(Door.load(door_saved));
}
if (next_door !== null) this.next_door_target = Date.now() + next_door;
} catch {
this.background_image = "error";
}
},
when_initialized(callback: () => void): void {
if (this.on_initialized === null) {
callback();
} else {
this.on_initialized.push(callback);
}
},
async update_is_admin(): Promise<boolean> {
this.is_admin = await API.request<boolean>("admin/is_admin");
return this.is_admin;
},
async login(creds: Credentials): Promise<boolean> {
API.creds = creds;
return await this.update_is_admin();
},
logout() {
API.creds = null;
this.is_admin = false;
},
toggle_touch_device(): void {
this.is_touch_device = !this.is_touch_device;
},
},
});
if (import.meta.webpackHot) {
import.meta.webpackHot.accept(
acceptHMRUpdate(advent22Store, import.meta.webpackHot),
);
}

View file

@ -1,64 +1,23 @@
@charset "utf-8"; @charset "utf-8";
@use "sass:map";
//=========== //==============
// variables // bulma
//=========== //==============
// custom color scheme // custom color scheme
@import "@/bulma-scheme"; @use "bulma-scheme" as scheme;
@use "bulma/sass" with (
// Sass variables (bulma) $primary: map.get(scheme.$colors, "primary"),
@import "~bulma/sass/utilities/initial-variables.sass"; $link: map.get(scheme.$colors, "link"),
@import "~bulma/sass/utilities/derived-variables.sass"; $info: map.get(scheme.$colors, "info"),
$success: map.get(scheme.$colors, "success"),
// Sass variables (bulma-prefers-dark) $warning: map.get(scheme.$colors, "warning"),
@import "~bulma-prefers-dark/sass/utilities/initial-variables.sass"; $danger: map.get(scheme.$colors, "danger")
@import "~bulma-prefers-dark/sass/utilities/derived-variables.sass"; );
//=================
// variable tweaks
//=================
$modal-card-body-background-color-dark: $body-background-dark;
$card-background-color-dark: $background-dark;
//============== //==============
// main imports // main imports
//============== //==============
@import "~animate.css/animate"; @forward "animate.css/animate";
@import "~bulma/bulma";
@import "~bulma-prefers-dark/bulma-prefers-dark";
//==============
// style tweaks
//==============
.card-header {
background-color: $background;
@include prefers-scheme(dark) {
background-color: $card-header-background-color;
}
}
.card-content {
@include prefers-scheme(dark) {
background-color: $body-background-dark;
}
}
.progress {
// &::-webkit-progress-bar {
// background-color: transparent !important;
// }
// &::-webkit-progress-value {
// background-color: transparent !important;
// }
&::-moz-progress-bar {
background-color: transparent !important;
}
// &::-ms-fill {
// background-color: transparent !important;
// }
}

View file

@ -1,7 +1,6 @@
import { Advent22Plugin } from "@/plugins/advent22"; import FontAwesomeIcon from "@/lib/fontawesome";
import { FontAwesomePlugin } from "@/plugins/fontawesome"; import { advent22Store } from "@/lib/store";
import { advent22Store } from "@/plugins/store"; import { setDefaults as toast_set_defaults } from "bulma-toast";
import * as bulmaToast from "bulma-toast";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import { createApp } from "vue"; import { createApp } from "vue";
import App from "./App.vue"; import App from "./App.vue";
@ -10,15 +9,14 @@ import "@/main.scss";
const app = createApp(App); const app = createApp(App);
app.use(Advent22Plugin);
app.use(FontAwesomePlugin);
app.use(createPinia()); app.use(createPinia());
app.component("FontAwesomeIcon", FontAwesomeIcon);
advent22Store().init(); advent22Store().init();
app.mount("#app"); app.mount("#app");
bulmaToast.setDefaults({ toast_set_defaults({
duration: 10e3, duration: 10e3,
pauseOnHover: true, pauseOnHover: true,
dismissible: true, dismissible: true,

View file

@ -1,110 +0,0 @@
import axios, { AxiosInstance, ResponseType } from "axios";
import { App, Plugin } from "vue";
import { advent22Store } from "./store";
export class Advent22 {
private axios: AxiosInstance;
public constructor() {
this.axios = axios.create({
timeout: 10e3,
});
}
private get api_baseurl(): string {
// in production mode, return "//host/api"
if (process.env.NODE_ENV === "production") {
return `//${window.location.host}/api`;
} else if (process.env.NODE_ENV !== "development") {
// not in prouction or development mode
console.warn("Unexpected NODE_ENV value");
}
// in development mode, return "//hostname:8000/api"
return `//${window.location.hostname}:8000/api`;
}
public name_door(day: number): string {
return `Türchen ${day}`;
}
public api_url(): string;
public api_url(endpoint: string): string;
public api_url(endpoint?: string): string {
if (endpoint === undefined) {
return this.api_baseurl;
}
while (endpoint.startsWith("/")) {
endpoint = endpoint.substring(1);
}
return `${this.api_baseurl}/${endpoint}`;
}
private _api_get<T>(endpoint: string): Promise<T>;
private _api_get<T>(endpoint: string, responseType: ResponseType): Promise<T>;
private _api_get<T>(
endpoint: string,
responseType: ResponseType = "json",
): Promise<T> {
const req_config = {
auth: advent22Store().axios_creds,
responseType: responseType,
};
return new Promise<T>((resolve, reject) => {
this.axios
.get<T>(this.api_url(endpoint), req_config)
.then((response) => resolve(response.data))
.catch((reason) => {
console.error(`Failed to query ${endpoint}: ${reason}`);
reject([reason, endpoint]);
});
});
}
public api_get<T>(endpoint: string): Promise<T> {
return this._api_get<T>(endpoint);
}
public api_get_blob(endpoint: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
this._api_get<Blob>(endpoint, "blob")
.then((data: Blob) => {
const reader = new FileReader();
reader.readAsDataURL(data);
reader.onloadend = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(["failed data url", endpoint]);
}
};
})
.catch(reject);
});
}
public api_put(endpoint: string, data: unknown): Promise<void> {
const req_config = {
auth: advent22Store().axios_creds,
};
return new Promise<void>((resolve, reject) => {
this.axios
.put(this.api_url(endpoint), data, req_config)
.then(() => resolve())
.catch((reason) => {
console.error(`Failed to query ${endpoint}: ${reason}`);
reject([reason, endpoint]);
});
});
}
}
export const Advent22Plugin: Plugin = {
install(app: App) {
app.config.globalProperties.$advent22 = new Advent22();
},
};

View file

@ -1,233 +0,0 @@
import { Credentials, DoorSaved, SiteConfigModel } from "@/lib/api";
import { Door } from "@/lib/door";
import { Advent22 } from "@/plugins/advent22";
import { RemovableRef, useLocalStorage } from "@vueuse/core";
import { AxiosBasicCredentials, AxiosError } from "axios";
import { toast } from "bulma-toast";
import { acceptHMRUpdate, defineStore } from "pinia";
declare global {
interface Navigator {
readonly msMaxTouchPoints: number;
}
}
type State = {
advent22: Advent22;
api_creds: RemovableRef<Credentials>;
is_initialized: boolean;
on_initialized: (() => void)[];
is_touch_device: boolean;
is_admin: boolean;
site_config: SiteConfigModel;
calendar_background_image: string | undefined;
calendar_aspect_ratio: number;
user_doors: Door[];
next_door_target: number | null;
};
export const advent22Store = defineStore({
id: "advent22",
state: (): State => ({
advent22: new Advent22(),
api_creds: useLocalStorage("advent22/auth", ["", ""]),
is_initialized: false,
on_initialized: [],
is_touch_device:
window.matchMedia("(any-hover: none)").matches ||
"ontouchstart" in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0,
is_admin: false,
site_config: {
title: document.title,
subtitle: "",
content: "",
footer: "",
},
calendar_background_image: undefined,
calendar_aspect_ratio: 1,
user_doors: [],
next_door_target: null,
}),
getters: {
axios_creds: (state): AxiosBasicCredentials => {
const [username, password] = state.api_creds;
return { username: username, password: password };
},
},
actions: {
init(): void {
this.update()
.then(() => {
this.is_initialized = true;
for (const callback of this.on_initialized) callback();
})
.catch(this.alert_user_error);
},
format_user_error([reason, endpoint]: [unknown, string]): string {
let msg =
"Unbekannter Fehler, bitte wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
let code = "U";
const result = () => `${msg} (Fehlercode: ${code}/${endpoint})`;
if (!(reason instanceof AxiosError)) return result();
switch (reason.code) {
case "ECONNABORTED":
// API unerreichbar
msg =
"API antwortet nicht, bitte später wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
code = "D";
break;
case "ERR_NETWORK":
// Netzwerk nicht verbunden
msg = "Sieht aus, als sei deine Netzwerkverbindung gestört.";
code = "N";
break;
default:
if (reason.response === undefined) return result();
switch (reason.response.status) {
case 401:
// UNAUTHORIZED
msg = "Netter Versuch :)";
code = "A";
break;
case 422:
// UNPROCESSABLE ENTITY
msg = "Funktion ist kaputt, bitte Admin benachrichtigen!";
code = "I";
break;
default:
// HTTP
code = `H${reason.response.status}`;
break;
}
break;
}
return result();
},
alert_user_error(param: [unknown, string]): void {
toast({
message: this.format_user_error(param),
type: "is-danger",
});
},
update(): Promise<void> {
return new Promise((resolve, reject) => {
this.advent22
.api_get_blob("user/favicon")
.then((favicon_src) => {
const link: HTMLLinkElement =
document.querySelector("link[rel*='icon']") ||
document.createElement("link");
link.rel = "shortcut icon";
link.type = "image/x-icon";
link.href = favicon_src;
if (link.parentElement === null)
document.getElementsByTagName("head")[0].appendChild(link);
})
.catch(() => {});
Promise.all([
this.update_is_admin(),
this.advent22.api_get<SiteConfigModel>("user/site_config"),
this.advent22.api_get_blob("user/background_image"),
this.advent22.api_get<DoorSaved[]>("user/doors"),
this.advent22.api_get<number | null>("user/next_door"),
])
.then(
([
is_admin,
site_config,
background_image,
user_doors,
next_door,
]) => {
is_admin; // discard value
document.title = site_config.title;
if (site_config.subtitle !== "")
document.title += " " + site_config.subtitle;
this.site_config = site_config;
this.calendar_background_image = background_image;
this.user_doors.length = 0;
for (const door_saved of user_doors) {
this.user_doors.push(Door.load(door_saved));
}
if (next_door !== null)
this.next_door_target = Date.now() + next_door;
resolve();
},
)
.catch(reject);
});
},
when_initialized(callback: () => void): void {
if (this.is_initialized) {
callback();
} else {
this.on_initialized.push(callback);
}
},
update_is_admin(): Promise<boolean> {
return new Promise((resolve, reject) => {
this.advent22
.api_get<boolean>("admin/is_admin")
.then((is_admin) => {
this.is_admin = is_admin;
resolve(is_admin);
})
.catch(reject);
});
},
login(creds: Credentials): Promise<boolean> {
this.api_creds = creds;
return this.update_is_admin();
},
logout(): Promise<boolean> {
return this.login(["", ""]);
},
toggle_touch_device(): void {
this.is_touch_device = !this.is_touch_device;
},
set_calendar_aspect_ratio(rect: DOMRectReadOnly): void {
const result = rect.width / rect.height;
// filter suspicious results
if (result !== 0 && isFinite(result) && !isNaN(result))
this.calendar_aspect_ratio = result;
},
},
});
if (import.meta.webpackHot) {
import.meta.webpackHot.accept(
acceptHMRUpdate(advent22Store, import.meta.webpackHot),
);
}

View file

@ -1,7 +1,7 @@
import { expect } from "chai"; import { expect } from "chai";
import { Rectangle } from "@/lib/rectangle"; import { Rectangle } from "@/lib/rects/rectangle";
import { Vector2D } from "@/lib/vector2d"; import { Vector2D } from "@/lib/rects/vector2d";
describe("Rectangle Tests", () => { describe("Rectangle Tests", () => {
const v1 = new Vector2D(1, 2); const v1 = new Vector2D(1, 2);
@ -16,7 +16,7 @@ describe("Rectangle Tests", () => {
top: number, top: number,
width: number, width: number,
height: number, height: number,
) { ): void {
expect(r.left).to.equal(left); expect(r.left).to.equal(left);
expect(r.top).to.equal(top); expect(r.top).to.equal(top);

View file

@ -1,6 +1,6 @@
import { expect } from "chai"; import { expect } from "chai";
import { Vector2D } from "@/lib/vector2d"; import { Vector2D } from "@/lib/rects/vector2d";
describe("Vector2D Tests", () => { describe("Vector2D Tests", () => {
const v = new Vector2D(1, 2); const v = new Vector2D(1, 2);

View file

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

View file

@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const { defineConfig } = require("@vue/cli-service"); const { defineConfig } = require("@vue/cli-service");
const webpack = require("webpack"); const webpack = require("webpack");
module.exports = defineConfig({ module.exports = defineConfig({
transpileDependencies: true, transpileDependencies: true,
devServer: { devServer: {
host: "localhost", host: "0.0.0.0",
}, },
pages: { pages: {
index: { index: {

File diff suppressed because it is too large Load diff