Compare commits

..

No commits in common. "6c0c45643af6c826bd1ef21525d8db969c29a29e" and "52e1a4042eb3c06de4368f98d699a094e258b821" have entirely different histories.

64 changed files with 4136 additions and 4093 deletions

View file

@ -1,4 +1,4 @@
[flake8]
max-line-length = 80
extend-select = B950
extend-ignore = E203,E501
select = C,E,F,W,B,B950
extend-ignore = E203, E501

View file

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

View file

@ -1,29 +1,22 @@
import colorsys
import logging
from dataclasses import dataclass
from typing import Self, TypeAlias, cast
import numpy as np
from PIL import Image as PILImage
from PIL import ImageDraw
from PIL.Image import Image, Resampling
from PIL.ImageFont import FreeTypeFont
from PIL import Image, ImageDraw, ImageFont
from .config import Config
_RGB: TypeAlias = tuple[int, int, int]
_XY: TypeAlias = tuple[float, float]
_Box: TypeAlias = tuple[int, int, int, int]
_logger = logging.getLogger(__name__)
@dataclass(slots=True, frozen=True)
class AdventImage:
img: Image
img: Image.Image
@classmethod
async def from_img(cls, img: Image, cfg: Config) -> Self:
async def from_img(cls, img: Image.Image, cfg: Config) -> Self:
"""
Einen quadratischen Ausschnitt aus der Mitte des Bilds nehmen
"""
@ -49,7 +42,7 @@ class AdventImage:
return cls(
img.resize(
size=(cfg.image.size, cfg.image.size),
resample=Resampling.LANCZOS,
resample=Image.LANCZOS,
)
)
@ -57,10 +50,10 @@ class AdventImage:
self,
xy: _XY,
text: str | bytes,
font: FreeTypeFont,
font: "ImageFont._Font",
anchor: str | None = "mm",
**text_kwargs,
) -> _Box | None:
) -> "Image._Box | None":
"""
Koordinaten (links, oben, rechts, unten) des betroffenen
Rechtecks bestimmen, wenn das Bild mit einem Text
@ -68,7 +61,7 @@ class AdventImage:
"""
# Neues 1-Bit Bild, gleiche Größe
mask = PILImage.new(mode="1", size=self.img.size)
mask = Image.new(mode="1", size=self.img.size, color=0)
# Text auf Maske auftragen
ImageDraw.Draw(mask).text(
@ -85,15 +78,15 @@ class AdventImage:
async def get_average_color(
self,
box: _Box,
) -> _RGB:
box: "Image._Box",
) -> tuple[int, int, int]:
"""
Durchschnittsfarbe eines rechteckigen Ausschnitts in
einem Bild berechnen
"""
pixel_data = np.asarray(self.img.crop(box))
mean_color: np.ndarray = np.mean(pixel_data, axis=(0, 1))
pixel_data = self.img.crop(box).getdata()
mean_color: np.ndarray = np.mean(pixel_data, axis=0)
return cast(_RGB, tuple(mean_color.astype(int)))
@ -101,7 +94,7 @@ class AdventImage:
self,
xy: _XY,
text: str | bytes,
font: FreeTypeFont,
font: "ImageFont._Font",
anchor: str | None = "mm",
**text_kwargs,
) -> None:
@ -115,10 +108,7 @@ class AdventImage:
xy=xy, text=text, font=font, anchor=anchor, **text_kwargs
)
if text_box is None:
_logger.warning("Konnte Bildbereich nicht finden!")
return
if text_box is not None:
# Durchschnittsfarbe bestimmen
text_color = await self.get_average_color(
box=text_box,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,18 +7,7 @@
</section>
<section class="section px-3">
<progress
v-if="store.background_image === 'loading'"
class="progress is-primary"
max="100"
/>
<div
v-else-if="store.background_image === 'error'"
class="notification is-danger"
>
Hintergrundbild konnte nicht geladen werden
</div>
<div v-else class="container">
<div class="container">
<AdminView v-if="store.is_admin" />
<UserView v-else />
</div>
@ -33,25 +22,36 @@
</div>
<div class="level-right">
<div class="level-item">
<TouchButton class="is-small is-warning" />
<TouchButton class="tag is-warning" />
</div>
<div class="level-item">
<AdminButton class="is-small is-link is-outlined" />
<AdminButton class="tag is-link is-outlined" />
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
import { advent22Store } from "./lib/store";
<script lang="ts">
import { Options, Vue } from "vue-class-component";
import { advent22Store } from "./plugins/store";
import AdminView from "./components/admin/AdminView.vue";
import AdminButton from "./components/AdminButton.vue";
import TouchButton from "./components/TouchButton.vue";
import UserView from "./components/UserView.vue";
const store = advent22Store();
@Options({
components: {
AdminView,
AdminButton,
TouchButton,
UserView,
},
})
export default class extends Vue {
public readonly store = advent22Store();
}
</script>
<style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
<template>
<template v-if="store.is_initialized === true">
<Calendar :doors="store.user_doors" />
<hr />
<div class="content" v-html="store.site_config.content" />
@ -17,13 +18,24 @@
<CountDown :until="store.next_door_target" />
</template>
</div>
</template>
<progress v-else class="progress is-primary" max="100" />
</template>
<script setup lang="ts">
import { advent22Store } from "@/lib/store";
<script lang="ts">
import { advent22Store } from "@/plugins/store";
import { Options, Vue } from "vue-class-component";
import Calendar from "./Calendar.vue";
import CountDown from "./CountDown.vue";
const store = advent22Store();
@Options({
components: {
Calendar,
CountDown,
},
})
export default class extends Vue {
public readonly store = advent22Store();
}
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

10
ui/src/d.ts/shims-advent22.d.ts vendored Normal file
View file

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

View file

@ -1,92 +1,71 @@
import type {
AxiosBasicCredentials,
AxiosRequestConfig,
Method,
RawAxiosRequestHeaders,
} from "axios";
import axios from "axios";
import { APIError } from "./api_error";
interface Params {
endpoint: string;
method?: Method;
data?: unknown;
headers?: RawAxiosRequestHeaders;
config?: AxiosRequestConfig;
}
export class API {
private static get api_baseurl(): string {
// in production mode, return "proto://hostname/api"
if (process.env.NODE_ENV === "production") {
return `${window.location.protocol}//${window.location.host}/api`;
} else if (process.env.NODE_ENV !== "development") {
// not in prouction or development mode
// eslint-disable-next-line no-console
console.warn("Unexpected NODE_ENV value: ", process.env.NODE_ENV);
}
// in development mode, return "proto://hostname:8000/api"
return `${window.location.protocol}//${window.location.hostname}:8000/api`;
}
private static readonly axios = axios.create({
timeout: 10e3,
baseURL: this.api_baseurl,
});
private static readonly creds_key = "advent22/credentials";
public static set creds(value: AxiosBasicCredentials | null) {
if (value === null) {
localStorage.removeItem(this.creds_key);
} else {
localStorage.setItem(this.creds_key, JSON.stringify(value));
}
}
public static get creds(): AxiosBasicCredentials {
const stored_auth = JSON.parse(localStorage.getItem(this.creds_key) ?? "");
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,
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;
};
}
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 };
export interface SiteConfigModel {
title: string;
subtitle: string;
content: string;
footer: string;
}
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);
export interface NumStrDict {
[key: number]: string;
}
export interface DoorSaved {
day: number;
x1: number;
y1: number;
x2: number;
y2: number;
}
export type Credentials = [username: string, password: string];
export function objForEach<T>(
obj: T,
f: (k: keyof T, v: T[keyof T]) => void,
): void {
for (const k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k)) {
f(k, obj[k]);
}
}
}

View file

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

View file

@ -1,52 +0,0 @@
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}`;
}

View file

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

View file

@ -67,7 +67,7 @@ export class 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 {

View file

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

View file

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

110
ui/src/plugins/advent22.ts Normal file
View file

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

233
ui/src/plugins/store.ts Normal file
View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff