mirror of
https://code.lenaisten.de/Lenaisten/advent22.git
synced 2026-02-25 02:20:17 +00:00
Merge branch 'feature/refactoring' into develop
finished refactoring of UI (vue3 w/ composition api)
This commit is contained in:
commit
6c0c45643a
64 changed files with 4095 additions and 4138 deletions
|
|
@ -1,4 +1,4 @@
|
|||
[flake8]
|
||||
max-line-length = 80
|
||||
select = C,E,F,W,B,B950
|
||||
extend-ignore = E203, E501
|
||||
extend-select = B950
|
||||
extend-ignore = E203,E501
|
||||
|
|
|
|||
3
api/.isort.cfg
Normal file
3
api/.isort.cfg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[settings]
|
||||
profile = black
|
||||
line_length = 80
|
||||
|
|
@ -1,22 +1,29 @@
|
|||
import colorsys
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Self, TypeAlias, cast
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import Image as PILImage
|
||||
from PIL import ImageDraw
|
||||
from PIL.Image import Image, Resampling
|
||||
from PIL.ImageFont import FreeTypeFont
|
||||
|
||||
from .config import Config
|
||||
|
||||
_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.Image
|
||||
img: Image
|
||||
|
||||
@classmethod
|
||||
async def from_img(cls, img: Image.Image, cfg: Config) -> Self:
|
||||
async def from_img(cls, img: Image, cfg: Config) -> Self:
|
||||
"""
|
||||
Einen quadratischen Ausschnitt aus der Mitte des Bilds nehmen
|
||||
"""
|
||||
|
|
@ -42,7 +49,7 @@ class AdventImage:
|
|||
return cls(
|
||||
img.resize(
|
||||
size=(cfg.image.size, cfg.image.size),
|
||||
resample=Image.LANCZOS,
|
||||
resample=Resampling.LANCZOS,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -50,10 +57,10 @@ class AdventImage:
|
|||
self,
|
||||
xy: _XY,
|
||||
text: str | bytes,
|
||||
font: "ImageFont._Font",
|
||||
font: FreeTypeFont,
|
||||
anchor: str | None = "mm",
|
||||
**text_kwargs,
|
||||
) -> "Image._Box | None":
|
||||
) -> _Box | None:
|
||||
"""
|
||||
Koordinaten (links, oben, rechts, unten) des betroffenen
|
||||
Rechtecks bestimmen, wenn das Bild mit einem Text
|
||||
|
|
@ -61,7 +68,7 @@ class AdventImage:
|
|||
"""
|
||||
|
||||
# Neues 1-Bit Bild, gleiche Größe
|
||||
mask = Image.new(mode="1", size=self.img.size, color=0)
|
||||
mask = PILImage.new(mode="1", size=self.img.size)
|
||||
|
||||
# Text auf Maske auftragen
|
||||
ImageDraw.Draw(mask).text(
|
||||
|
|
@ -78,15 +85,15 @@ class AdventImage:
|
|||
|
||||
async def get_average_color(
|
||||
self,
|
||||
box: "Image._Box",
|
||||
) -> tuple[int, int, int]:
|
||||
box: _Box,
|
||||
) -> _RGB:
|
||||
"""
|
||||
Durchschnittsfarbe eines rechteckigen Ausschnitts in
|
||||
einem Bild berechnen
|
||||
"""
|
||||
|
||||
pixel_data = self.img.crop(box).getdata()
|
||||
mean_color: np.ndarray = np.mean(pixel_data, axis=0)
|
||||
pixel_data = np.asarray(self.img.crop(box))
|
||||
mean_color: np.ndarray = np.mean(pixel_data, axis=(0, 1))
|
||||
|
||||
return cast(_RGB, tuple(mean_color.astype(int)))
|
||||
|
||||
|
|
@ -94,7 +101,7 @@ class AdventImage:
|
|||
self,
|
||||
xy: _XY,
|
||||
text: str | bytes,
|
||||
font: "ImageFont._Font",
|
||||
font: FreeTypeFont,
|
||||
anchor: str | None = "mm",
|
||||
**text_kwargs,
|
||||
) -> None:
|
||||
|
|
@ -108,31 +115,34 @@ class AdventImage:
|
|||
xy=xy, text=text, font=font, anchor=anchor, **text_kwargs
|
||||
)
|
||||
|
||||
if text_box is not None:
|
||||
# Durchschnittsfarbe bestimmen
|
||||
text_color = await self.get_average_color(
|
||||
box=text_box,
|
||||
)
|
||||
if text_box is None:
|
||||
_logger.warning("Konnte Bildbereich nicht finden!")
|
||||
return
|
||||
|
||||
# etwas heller/dunkler machen
|
||||
tc_h, tc_s, tc_v = colorsys.rgb_to_hsv(*text_color)
|
||||
tc_v = int((tc_v - 127) * 0.97) + 127
|
||||
# Durchschnittsfarbe bestimmen
|
||||
text_color = await self.get_average_color(
|
||||
box=text_box,
|
||||
)
|
||||
|
||||
if tc_v < 127:
|
||||
tc_v += 3
|
||||
# etwas heller/dunkler machen
|
||||
tc_h, tc_s, tc_v = colorsys.rgb_to_hsv(*text_color)
|
||||
tc_v = int((tc_v - 127) * 0.97) + 127
|
||||
|
||||
else:
|
||||
tc_v -= 3
|
||||
if tc_v < 127:
|
||||
tc_v += 3
|
||||
|
||||
text_color = colorsys.hsv_to_rgb(tc_h, tc_s, tc_v)
|
||||
text_color = tuple(int(val) for val in text_color)
|
||||
else:
|
||||
tc_v -= 3
|
||||
|
||||
# Buchstaben verstecken
|
||||
ImageDraw.Draw(self.img).text(
|
||||
xy=xy,
|
||||
text=text,
|
||||
font=font,
|
||||
fill=cast(_RGB, text_color),
|
||||
anchor=anchor,
|
||||
**text_kwargs,
|
||||
)
|
||||
text_color = colorsys.hsv_to_rgb(tc_h, tc_s, tc_v)
|
||||
text_color = tuple(int(val) for val in text_color)
|
||||
|
||||
# Buchstaben verstecken
|
||||
ImageDraw.Draw(self.img).text(
|
||||
xy=xy,
|
||||
text=text,
|
||||
font=font,
|
||||
fill=cast(_RGB, text_color),
|
||||
anchor=anchor,
|
||||
**text_kwargs,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,15 +4,10 @@ from markdown import markdown
|
|||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from .dav.webdav import WebDAV
|
||||
from .settings import SETTINGS
|
||||
from .settings import SETTINGS, Credentials
|
||||
from .transformed_string import TransformedString
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
name: str
|
||||
password: str
|
||||
|
||||
|
||||
class Site(BaseModel):
|
||||
model_config = ConfigDict(validate_default=True)
|
||||
|
||||
|
|
@ -60,7 +55,7 @@ class Image(BaseModel):
|
|||
|
||||
class Config(BaseModel):
|
||||
# Login-Daten für Admin-Modus
|
||||
admin: User
|
||||
admin: Credentials
|
||||
|
||||
# Lösungswort
|
||||
solution: TransformedString
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ class WebDAV:
|
|||
_webdav_client = WebDAVclient(
|
||||
{
|
||||
"webdav_hostname": SETTINGS.webdav.url,
|
||||
"webdav_login": SETTINGS.webdav.username,
|
||||
"webdav_password": SETTINGS.webdav.password,
|
||||
"webdav_login": SETTINGS.webdav.auth.username,
|
||||
"webdav_password": SETTINGS.webdav.auth.password,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ from io import BytesIO
|
|||
from typing import cast
|
||||
|
||||
from fastapi import Depends
|
||||
from PIL import Image, ImageFont
|
||||
from PIL import ImageFont
|
||||
from PIL.Image import Image
|
||||
from PIL.ImageFont import FreeTypeFont
|
||||
|
||||
from .advent_image import _XY, AdventImage
|
||||
from .calendar_config import CalendarConfig, get_calendar_config
|
||||
|
|
@ -22,6 +24,8 @@ 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),
|
||||
|
|
@ -107,11 +111,10 @@ 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 := num_re.search(name)) is not None
|
||||
if (num_match := RE_NUM.search(name)) is not None
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -138,7 +141,7 @@ class TTFont:
|
|||
size: int = 50
|
||||
|
||||
@property
|
||||
async def font(self) -> "ImageFont._Font":
|
||||
async def font(self) -> FreeTypeFont:
|
||||
return ImageFont.truetype(
|
||||
font=BytesIO(await WebDAV.read_bytes(self.file_name)),
|
||||
size=100,
|
||||
|
|
@ -169,7 +172,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
|
||||
"""
|
||||
|
|
@ -200,7 +203,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.Image | None:
|
||||
) -> Image | None:
|
||||
"""
|
||||
Bild für einen Tag abrufen
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import base64
|
||||
import itertools
|
||||
import random
|
||||
import re
|
||||
|
|
@ -5,8 +6,9 @@ from datetime import date, datetime, timedelta
|
|||
from io import BytesIO
|
||||
from typing import Any, Awaitable, Callable, Iterable, Self, Sequence, TypeVar
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
from PIL import Image
|
||||
from PIL import Image as PILImage
|
||||
from PIL.Image import Image, Resampling
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .config import get_config
|
||||
from .dav.webdav import WebDAV
|
||||
|
|
@ -103,7 +105,7 @@ list_images_manual = list_helper("/images_manual", RE_IMG)
|
|||
list_fonts = list_helper("/files", RE_TTF)
|
||||
|
||||
|
||||
async def load_image(file_name: str) -> Image.Image:
|
||||
async def load_image(file_name: str) -> Image:
|
||||
"""
|
||||
Versuche, Bild aus Datei zu laden
|
||||
"""
|
||||
|
|
@ -111,28 +113,54 @@ async def load_image(file_name: str) -> Image.Image:
|
|||
if not await WebDAV.exists(file_name):
|
||||
raise RuntimeError(f"DAV-File {file_name} does not exist!")
|
||||
|
||||
return Image.open(BytesIO(await WebDAV.read_bytes(file_name)))
|
||||
return PILImage.open(BytesIO(await WebDAV.read_bytes(file_name)))
|
||||
|
||||
|
||||
async def api_return_ico(img: Image.Image) -> StreamingResponse:
|
||||
class ImageData(BaseModel):
|
||||
width: int
|
||||
height: int
|
||||
aspect_ratio: float
|
||||
data_url: str
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
media_type: str,
|
||||
content: BytesIO,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> Self:
|
||||
img_data = base64.b64encode(content.getvalue()).decode("utf-8")
|
||||
|
||||
return cls(
|
||||
width=width,
|
||||
height=height,
|
||||
aspect_ratio=width / height,
|
||||
data_url=f"data:{media_type};base64,{img_data}",
|
||||
)
|
||||
|
||||
|
||||
async def api_return_ico(img: Image) -> ImageData:
|
||||
"""
|
||||
ICO-Bild mit API zurückgeben
|
||||
"""
|
||||
|
||||
# JPEG-Daten in Puffer speichern
|
||||
# ICO-Daten in Puffer speichern (256px)
|
||||
img_buffer = BytesIO()
|
||||
img.resize(size=(256, 256), resample=Image.LANCZOS)
|
||||
img.resize(size=(256, 256), resample=Resampling.LANCZOS)
|
||||
img.save(img_buffer, format="ICO")
|
||||
img_buffer.seek(0)
|
||||
|
||||
# zurückgeben
|
||||
return StreamingResponse(
|
||||
return ImageData.create(
|
||||
media_type="image/x-icon",
|
||||
content=img_buffer,
|
||||
width=img.width,
|
||||
height=img.height,
|
||||
)
|
||||
|
||||
|
||||
async def api_return_jpeg(img: Image.Image) -> StreamingResponse:
|
||||
async def api_return_jpeg(img: Image) -> ImageData:
|
||||
"""
|
||||
JPEG-Bild mit API zurückgeben
|
||||
"""
|
||||
|
|
@ -140,12 +168,13 @@ async def api_return_jpeg(img: Image.Image) -> StreamingResponse:
|
|||
# JPEG-Daten in Puffer speichern
|
||||
img_buffer = BytesIO()
|
||||
img.save(img_buffer, format="JPEG", quality=85)
|
||||
img_buffer.seek(0)
|
||||
|
||||
# zurückgeben
|
||||
return StreamingResponse(
|
||||
return ImageData.create(
|
||||
media_type="image/jpeg",
|
||||
content=img_buffer,
|
||||
width=img.width,
|
||||
height=img.height,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Credentials(BaseModel):
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
|
||||
|
||||
class DavSettings(BaseModel):
|
||||
"""
|
||||
Connection to a DAV server.
|
||||
|
|
@ -16,8 +21,10 @@ class DavSettings(BaseModel):
|
|||
path: str = "/remote.php/webdav"
|
||||
prefix: str = "/advent22"
|
||||
|
||||
username: str = "advent22_user"
|
||||
password: str = "password"
|
||||
auth: Credentials = Credentials(
|
||||
username="advent22_user",
|
||||
password="password",
|
||||
)
|
||||
|
||||
cache_ttl: int = 60 * 10
|
||||
config_filename: str = "config.toml"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ async def user_is_admin(
|
|||
|
||||
username_correct = secrets.compare_digest(
|
||||
credentials.username.lower(),
|
||||
cfg.admin.name.lower(),
|
||||
cfg.admin.username.lower(),
|
||||
)
|
||||
password_correct = secrets.compare_digest(
|
||||
credentials.password,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ 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,
|
||||
|
|
@ -14,7 +18,7 @@ from ..core.depends import (
|
|||
get_all_parts,
|
||||
get_all_ttfonts,
|
||||
)
|
||||
from ..core.settings import SETTINGS, RedisSettings
|
||||
from ..core.settings import SETTINGS, Credentials, RedisSettings
|
||||
from ._security import require_admin, user_is_admin
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
|
@ -170,24 +174,16 @@ async def put_doors(
|
|||
await cal_cfg.change(cfg)
|
||||
|
||||
|
||||
@router.get("/dav_credentials")
|
||||
async def get_dav_credentials(
|
||||
_: None = Depends(require_admin),
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Zugangsdaten für WebDAV
|
||||
"""
|
||||
|
||||
return SETTINGS.webdav.username, SETTINGS.webdav.password
|
||||
|
||||
|
||||
@router.get("/ui_credentials")
|
||||
async def get_ui_credentials(
|
||||
@router.get("/credentials/{name}")
|
||||
async def get_credentials(
|
||||
name: str,
|
||||
_: None = Depends(require_admin),
|
||||
cfg: Config = Depends(get_config),
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Zugangsdaten für Admin-UI
|
||||
"""
|
||||
) -> Credentials:
|
||||
|
||||
return cfg.admin.name, cfg.admin.password
|
||||
if name == "dav":
|
||||
return SETTINGS.webdav.auth
|
||||
elif name == "ui":
|
||||
return cfg.admin
|
||||
else:
|
||||
return Credentials()
|
||||
|
|
|
|||
|
|
@ -1,25 +1,31 @@
|
|||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from PIL import Image
|
||||
from PIL.Image 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, api_return_ico, api_return_jpeg, load_image
|
||||
from ..core.helpers import (
|
||||
EventDates,
|
||||
ImageData,
|
||||
api_return_ico,
|
||||
api_return_jpeg,
|
||||
load_image,
|
||||
)
|
||||
from ._security import user_can_view_day, user_is_admin, user_visible_days
|
||||
|
||||
router = APIRouter(prefix="/user", tags=["user"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/background_image",
|
||||
response_class=StreamingResponse,
|
||||
)
|
||||
@router.get("/background_image")
|
||||
async def get_background_image(
|
||||
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||
) -> StreamingResponse:
|
||||
) -> ImageData:
|
||||
"""
|
||||
Hintergrundbild laden
|
||||
"""
|
||||
|
|
@ -27,13 +33,10 @@ async def get_background_image(
|
|||
return await api_return_jpeg(await load_image(f"files/{cal_cfg.background}"))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/favicon",
|
||||
response_class=StreamingResponse,
|
||||
)
|
||||
@router.get("/favicon")
|
||||
async def get_favicon(
|
||||
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||
) -> StreamingResponse:
|
||||
) -> ImageData:
|
||||
"""
|
||||
Favicon laden
|
||||
"""
|
||||
|
|
@ -68,15 +71,12 @@ async def get_doors(
|
|||
return [door for door in cal_cfg.doors if door.day in visible_days]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/image_{day}",
|
||||
response_class=StreamingResponse,
|
||||
)
|
||||
@router.get("/image_{day}")
|
||||
async def get_image_for_day(
|
||||
user_can_view: bool = Depends(user_can_view_day),
|
||||
is_admin: bool = Depends(user_is_admin),
|
||||
image: Image.Image | None = Depends(get_day_image),
|
||||
) -> StreamingResponse:
|
||||
image: Image | None = Depends(get_day_image),
|
||||
) -> ImageData:
|
||||
"""
|
||||
Bild für einen Tag erstellen
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -2,15 +2,19 @@
|
|||
// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/javascript-node
|
||||
{
|
||||
"name": "Advent22 UI",
|
||||
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/vscode/devcontainers/javascript-node:1-18-bookworm",
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:4-20-trixie",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/git-lfs:1": {},
|
||||
"ghcr.io/devcontainers-extra/features/apt-get-packages:1": {
|
||||
"packages": "git-flow, git-lfs"
|
||||
"packages": "git-flow"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/vue-cli:2": {}
|
||||
},
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
|
|
@ -29,11 +33,16 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "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"
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,33 +2,37 @@ 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-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
|
||||
"no-empty": "off",
|
||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
},
|
||||
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'**/__tests__/*.{j,t}s?(x)',
|
||||
'**/tests/unit/**/*.spec.{j,t}s?(x)'
|
||||
"**/__tests__/*.{j,t}s?(x)",
|
||||
"**/tests/unit/**/*.spec.{j,t}s?(x)",
|
||||
],
|
||||
env: {
|
||||
mocha: true
|
||||
mocha: true,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-expressions": "off",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
6
ui/.vscode/extensions.json
vendored
6
ui/.vscode/extensions.json
vendored
|
|
@ -1,5 +1,3 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"sdras.vue-vscode-snippets"
|
||||
]
|
||||
}
|
||||
"recommendations": ["sdras.vue-vscode-snippets"]
|
||||
}
|
||||
|
|
|
|||
2
ui/.vscode/launch.json
vendored
2
ui/.vscode/launch.json
vendored
|
|
@ -12,4 +12,4 @@
|
|||
"webRoot": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
ui/.vscode/settings.json
vendored
19
ui/.vscode/settings.json
vendored
|
|
@ -1,22 +1,23 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"[vue]": {
|
||||
"[scss][vue][typescript][javascript][json][jsonc][jsonl]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
|
||||
"[jsonc]": {
|
||||
"editor.formatOnSave": false,
|
||||
},
|
||||
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
ui/.vscode/tasks.json
vendored
22
ui/.vscode/tasks.json
vendored
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "serve",
|
||||
"problemMatcher": [],
|
||||
"label": "UI starten",
|
||||
"detail": "vue-cli-service serve"
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "serve",
|
||||
"problemMatcher": [],
|
||||
"label": "UI starten",
|
||||
"detail": "vue-cli-service serve"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,29 @@
|
|||
# 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/).
|
||||
|
|
|
|||
|
|
@ -3,45 +3,44 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8080",
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"test:unit": "vue-cli-service test:unit",
|
||||
"test:unit-watch": "vue-cli-service test:unit --watch",
|
||||
"lint": "vue-cli-service lint"
|
||||
"lint": "vue-cli-service lint",
|
||||
"ui": "vue ui --host 0.0.0.0 --headless"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||
"@types/chai": "^4.3.14",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/mocha": "^10.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
||||
"@typescript-eslint/parser": "^7.3.1",
|
||||
"@vue/cli-plugin-babel": "~5.0.0",
|
||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||
"@vue/cli-plugin-typescript": "~5.0.0",
|
||||
"@vue/cli-plugin-unit-mocha": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"@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",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vue/test-utils": "^2.4.5",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "^1.6.8",
|
||||
"bulma": "^0.9.4",
|
||||
"bulma-prefers-dark": "^0.1.0-beta.1",
|
||||
"axios": "^1.13.5",
|
||||
"bulma": "^1.0.4",
|
||||
"bulma-toast": "2.4.3",
|
||||
"chai": "^4.3.10",
|
||||
"core-js": "^3.36.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.23.0",
|
||||
"luxon": "^3.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"sass": "^1.72.0",
|
||||
"sass-loader": "^14.1.1",
|
||||
"typescript": "~5.4.3",
|
||||
"vue": "^3.4.21",
|
||||
"vue-class-component": "^8.0.0-0"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,38 @@
|
|||
<!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 -->
|
||||
|
|
|
|||
|
|
@ -7,7 +7,18 @@
|
|||
</section>
|
||||
|
||||
<section class="section px-3">
|
||||
<div class="container">
|
||||
<progress
|
||||
v-if="store.background_image === 'loading'"
|
||||
class="progress is-primary"
|
||||
max="100"
|
||||
/>
|
||||
<div
|
||||
v-else-if="store.background_image === 'error'"
|
||||
class="notification is-danger"
|
||||
>
|
||||
Hintergrundbild konnte nicht geladen werden
|
||||
</div>
|
||||
<div v-else class="container">
|
||||
<AdminView v-if="store.is_admin" />
|
||||
<UserView v-else />
|
||||
</div>
|
||||
|
|
@ -22,36 +33,25 @@
|
|||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<TouchButton class="tag is-warning" />
|
||||
<TouchButton class="is-small is-warning" />
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<AdminButton class="tag is-link is-outlined" />
|
||||
<AdminButton class="is-small is-link is-outlined" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { advent22Store } from "./plugins/store";
|
||||
<script setup lang="ts">
|
||||
import { advent22Store } from "./lib/store";
|
||||
|
||||
import AdminView from "./components/admin/AdminView.vue";
|
||||
import AdminButton from "./components/AdminButton.vue";
|
||||
import TouchButton from "./components/TouchButton.vue";
|
||||
import UserView from "./components/UserView.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
AdminView,
|
||||
AdminButton,
|
||||
TouchButton,
|
||||
UserView,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
}
|
||||
const store = advent22Store();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,14 @@
|
|||
@charset "utf-8";
|
||||
@use "sass:map";
|
||||
|
||||
//=====================
|
||||
// custom color scheme
|
||||
//=====================
|
||||
|
||||
$advent22-colors: (
|
||||
"primary": #945DE1,
|
||||
"link": #64B4BD,
|
||||
"info": #8C4E80,
|
||||
"success": #7E8E2B,
|
||||
"warning": #F6CA6B,
|
||||
"danger": #C5443B,
|
||||
$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");
|
||||
|
|
@ -3,54 +3,50 @@
|
|||
|
||||
<BulmaButton
|
||||
v-bind="$attrs"
|
||||
:icon="'fa-solid fa-toggle-' + (store.is_admin ? 'on' : 'off')"
|
||||
:icon="['fas', store.is_admin ? 'fa-toggle-on' : 'fa-toggle-off']"
|
||||
:busy="is_busy"
|
||||
text="Admin"
|
||||
@click.left="on_click"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Credentials } from "@/lib/api";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<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";
|
||||
|
||||
import BulmaButton from "./bulma/Button.vue";
|
||||
import LoginModal from "./LoginModal.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaButton,
|
||||
LoginModal,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public modal_visible = false;
|
||||
public is_busy = false;
|
||||
public readonly store = advent22Store();
|
||||
const modal_visible = ref(false);
|
||||
const is_busy = ref(false);
|
||||
const store = advent22Store();
|
||||
|
||||
public on_click() {
|
||||
if (this.store.is_admin) {
|
||||
this.store.logout();
|
||||
} else {
|
||||
// show login modal
|
||||
this.is_busy = true;
|
||||
this.modal_visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
public on_submit(creds: Credentials) {
|
||||
this.modal_visible = false;
|
||||
|
||||
this.store
|
||||
.login(creds)
|
||||
.catch(this.store.alert_user_error)
|
||||
.finally(() => (this.is_busy = false));
|
||||
}
|
||||
|
||||
public on_cancel() {
|
||||
this.modal_visible = false;
|
||||
this.is_busy = false;
|
||||
function on_click(): void {
|
||||
if (store.is_admin) {
|
||||
store.logout();
|
||||
} else {
|
||||
// show login modal
|
||||
is_busy.value = true;
|
||||
modal_visible.value = 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;
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<!-- eslint-disable vue/multi-word-component-names -->
|
||||
<template>
|
||||
<MultiModal @handle="modal_handle" />
|
||||
<MultiModal @handle="on_modal_handle" />
|
||||
|
||||
<BulmaToast @handle="toast_handle" class="content">
|
||||
<BulmaToast @handle="on_toast_handle" class="content">
|
||||
<p>
|
||||
Du hast noch keine Türchen geöffnet, vielleicht gibt es ein Anzeigeproblem
|
||||
in Deinem Webbrowser?
|
||||
|
|
@ -29,14 +30,14 @@
|
|||
|
||||
<figure>
|
||||
<div class="image is-unselectable">
|
||||
<img :src="store.calendar_background_image" />
|
||||
<img :src="unwrap_loading(store.background_image).data_url" />
|
||||
<ThouCanvas>
|
||||
<CalendarDoor
|
||||
v-for="(door, index) in doors"
|
||||
:key="`door-${index}`"
|
||||
:door="door"
|
||||
:visible="store.is_touch_device"
|
||||
:title="$advent22.name_door(door.day)"
|
||||
:title="name_door(door.day)"
|
||||
@click="door_click(door.day)"
|
||||
style="cursor: pointer"
|
||||
/>
|
||||
|
|
@ -45,77 +46,65 @@
|
|||
</figure>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Door } from "@/lib/door";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<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";
|
||||
|
||||
import MultiModal from "./MultiModal.vue";
|
||||
import { onBeforeUnmount } from "vue";
|
||||
import MultiModal, { type HMultiModal } from "./MultiModal.vue";
|
||||
import BulmaButton from "./bulma/Button.vue";
|
||||
import BulmaToast from "./bulma/Toast.vue";
|
||||
import BulmaToast, { type HBulmaToast } from "./bulma/Toast.vue";
|
||||
import CalendarDoor from "./calendar/CalendarDoor.vue";
|
||||
import ThouCanvas from "./calendar/ThouCanvas.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
MultiModal,
|
||||
BulmaButton,
|
||||
BulmaToast,
|
||||
ThouCanvas,
|
||||
CalendarDoor,
|
||||
},
|
||||
props: {
|
||||
doors: Array,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly doors!: Door[];
|
||||
public readonly store = advent22Store();
|
||||
defineProps<{
|
||||
doors: VueLike<Door>[];
|
||||
}>();
|
||||
|
||||
private multi_modal?: MultiModal;
|
||||
const store = advent22Store();
|
||||
|
||||
public toast?: BulmaToast;
|
||||
private toast_timeout?: number;
|
||||
let modal: HMultiModal | undefined;
|
||||
let toast: HBulmaToast | undefined;
|
||||
let toast_timeout: number | undefined;
|
||||
|
||||
public modal_handle(modal: MultiModal) {
|
||||
this.multi_modal = modal;
|
||||
}
|
||||
function on_modal_handle(handle: HMultiModal): void {
|
||||
modal = handle;
|
||||
}
|
||||
|
||||
public toast_handle(toast: BulmaToast) {
|
||||
this.toast = toast;
|
||||
function on_toast_handle(handle: HBulmaToast): void {
|
||||
toast = handle;
|
||||
|
||||
if (this.store.is_touch_device) 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;
|
||||
store.when_initialized(() => {
|
||||
toast_timeout = window.setTimeout(() => {
|
||||
if (store.user_doors.length === 0) return;
|
||||
if (store.is_touch_device) return;
|
||||
|
||||
this.toast!.show({ duration: 600000, type: "is-warning" });
|
||||
}, 10e3);
|
||||
});
|
||||
}
|
||||
toast!.show({ duration: 600000, type: "is-warning" });
|
||||
}, 10e3);
|
||||
});
|
||||
}
|
||||
|
||||
public door_click(day: number) {
|
||||
if (this.toast_timeout !== undefined) clearTimeout(this.toast_timeout);
|
||||
this.toast?.hide();
|
||||
async function door_click(day: number): Promise<void> {
|
||||
window.clearTimeout(toast_timeout);
|
||||
toast?.hide();
|
||||
|
||||
if (this.multi_modal === undefined) return;
|
||||
this.multi_modal.show_progress();
|
||||
if (modal === undefined) return;
|
||||
modal.show_loading();
|
||||
|
||||
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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => toast?.hide());
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,31 +2,27 @@
|
|||
{{ string_repr }}
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { Duration } from "luxon";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
@Options({
|
||||
props: {
|
||||
until: Number,
|
||||
tick_time: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
private until!: number;
|
||||
private tick_time!: number;
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
until: number;
|
||||
tick_time?: number;
|
||||
}>(),
|
||||
{ tick_time: 200 },
|
||||
);
|
||||
|
||||
private interval_id: number | null = null;
|
||||
public string_repr = "";
|
||||
let interval_id: number | undefined;
|
||||
const string_repr = ref("");
|
||||
|
||||
private tick(): void {
|
||||
const distance_ms = this.until - Date.now();
|
||||
onMounted(() => {
|
||||
function tick(): void {
|
||||
const distance_ms = props.until - Date.now();
|
||||
|
||||
if (distance_ms <= 0) {
|
||||
this.string_repr = "Jetzt!";
|
||||
string_repr.value = "Jetzt!";
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -35,21 +31,18 @@ export default class extends Vue {
|
|||
const d_hms = distance.minus(d_days).shiftTo("hour", "minute", "second");
|
||||
|
||||
if (d_days.days > 0) {
|
||||
this.string_repr = d_days.toHuman() + " ";
|
||||
string_repr.value = d_days.toHuman() + " ";
|
||||
} else {
|
||||
this.string_repr = "";
|
||||
string_repr.value = "";
|
||||
}
|
||||
this.string_repr += d_hms.toFormat("hh:mm:ss");
|
||||
string_repr.value += d_hms.toFormat("hh:mm:ss");
|
||||
}
|
||||
|
||||
public mounted(): void {
|
||||
this.tick();
|
||||
this.interval_id = window.setInterval(this.tick, this.tick_time);
|
||||
}
|
||||
tick();
|
||||
interval_id = window.setInterval(tick, props.tick_time);
|
||||
});
|
||||
|
||||
public beforeUnmount(): void {
|
||||
if (this.interval_id === null) return;
|
||||
window.clearInterval(this.interval_id);
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
window.clearInterval(interval_id);
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
ref="username_input"
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="username"
|
||||
v-model="creds.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="password" />
|
||||
<input class="input" type="password" v-model="creds.password" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -33,13 +33,13 @@
|
|||
<BulmaButton
|
||||
class="is-success"
|
||||
@click.left="submit"
|
||||
icon="fa-solid fa-unlock"
|
||||
:icon="['fas', 'fa-unlock']"
|
||||
text="Login"
|
||||
/>
|
||||
<BulmaButton
|
||||
class="is-danger"
|
||||
@click.left="cancel"
|
||||
icon="fa-solid fa-circle-xmark"
|
||||
:icon="['fas', 'fa-circle-xmark']"
|
||||
text="Abbrechen"
|
||||
/>
|
||||
</footer>
|
||||
|
|
@ -47,48 +47,47 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
<script setup lang="ts">
|
||||
import { wait_for } from "@/lib/helpers";
|
||||
import type { Credentials } from "@/lib/model";
|
||||
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue";
|
||||
import BulmaButton from "./bulma/Button.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaButton,
|
||||
},
|
||||
props: {
|
||||
visible: Boolean,
|
||||
},
|
||||
emits: ["cancel", "submit"],
|
||||
})
|
||||
export default class extends Vue {
|
||||
public username = "";
|
||||
public password = "";
|
||||
const username_input = useTemplateRef("username_input");
|
||||
|
||||
private on_keydown(e: KeyboardEvent) {
|
||||
if (e.key == "Enter") this.submit();
|
||||
else if (e.key == "Escape") this.cancel();
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
(event: "submit", creds: Credentials): void;
|
||||
(event: "cancel"): void;
|
||||
}>();
|
||||
|
||||
public mounted(): void {
|
||||
window.addEventListener("keydown", this.on_keydown);
|
||||
const creds = ref<Credentials>({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,83 +1,79 @@
|
|||
<template>
|
||||
<div class="modal is-active" v-if="active" @click="dismiss()">
|
||||
<div v-if="state.show !== 'none'" class="modal is-active" @click="dismiss()">
|
||||
<div class="modal-background" />
|
||||
|
||||
<div class="modal-content" style="max-height: 100vh; max-width: 95vw">
|
||||
<template v-if="progress">
|
||||
<template v-if="state.show === 'loading'">
|
||||
<progress class="progress is-primary" max="100" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-else-if="state.show === 'image'">
|
||||
<figure>
|
||||
<figcaption class="tag is-primary">
|
||||
{{ caption }}
|
||||
{{ state.caption }}
|
||||
</figcaption>
|
||||
<div class="image is-square">
|
||||
<img :src="image_src" alt="Kalender-Bild" />
|
||||
<img :src="state.src" alt="Kalender-Bild" />
|
||||
</div>
|
||||
</figure>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!progress"
|
||||
v-if="state.show !== 'loading'"
|
||||
class="modal-close is-large has-background-primary"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
@Options({
|
||||
emits: ["handle"],
|
||||
})
|
||||
export default class extends Vue {
|
||||
public active = false;
|
||||
public progress = false;
|
||||
public image_src = "";
|
||||
public caption = "";
|
||||
type ModalState =
|
||||
| { show: "none" }
|
||||
| { show: "loading" }
|
||||
| { show: "image"; src: string; caption: string };
|
||||
|
||||
private on_keydown(e: KeyboardEvent) {
|
||||
if (e.key == "Escape") this.dismiss();
|
||||
}
|
||||
const state = ref<ModalState>({ show: "none" });
|
||||
|
||||
public created(): void {
|
||||
this.$emit("handle", this);
|
||||
}
|
||||
export type HMultiModal = {
|
||||
show_image(src: string, caption: string): void;
|
||||
show_loading(): void;
|
||||
hide(): void;
|
||||
};
|
||||
|
||||
public mounted(): void {
|
||||
window.addEventListener("keydown", this.on_keydown);
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
(event: "handle", handle: HMultiModal): void;
|
||||
}>();
|
||||
|
||||
public beforeUnmount(): void {
|
||||
window.removeEventListener("keydown", this.on_keydown);
|
||||
}
|
||||
function hide(): void {
|
||||
state.value = { show: "none" };
|
||||
}
|
||||
|
||||
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();
|
||||
function dismiss(): void {
|
||||
if (state.value.show !== "loading") {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -2,27 +2,16 @@
|
|||
<span>Eingabemodus: </span>
|
||||
<BulmaButton
|
||||
v-bind="$attrs"
|
||||
:icon="
|
||||
'fa-solid fa-' +
|
||||
(store.is_touch_device ? 'hand-pointer' : 'arrow-pointer')
|
||||
"
|
||||
:icon="['fas', store.is_touch_device ? 'hand-pointer' : 'arrow-pointer']"
|
||||
:text="store.is_touch_device ? 'Touch' : 'Desktop'"
|
||||
@click.left="store.toggle_touch_device"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import { advent22Store } from "@/lib/store";
|
||||
|
||||
import BulmaButton from "./bulma/Button.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaButton,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
}
|
||||
const store = advent22Store();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,29 @@
|
|||
<template>
|
||||
<template v-if="store.is_initialized === true">
|
||||
<Calendar :doors="store.user_doors" />
|
||||
<hr />
|
||||
<div class="content" v-html="store.site_config.content" />
|
||||
<div class="content has-text-primary">
|
||||
<template v-if="store.next_door_target === null">
|
||||
Alle {{ store.user_doors.length }} Türchen offen!
|
||||
<Calendar :doors="store.user_doors" />
|
||||
<hr />
|
||||
<div class="content" v-html="store.site_config.content" />
|
||||
<div class="content has-text-primary">
|
||||
<template v-if="store.next_door_target === null">
|
||||
Alle {{ store.user_doors.length }} Türchen offen!
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="store.user_doors.length === 0">
|
||||
Zeit bis zum ersten Türchen:
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="store.user_doors.length === 0">
|
||||
Zeit bis zum ersten Türchen:
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ store.user_doors.length }} Türchen offen. Zeit bis zum nächsten
|
||||
Türchen:
|
||||
</template>
|
||||
<CountDown :until="store.next_door_target" />
|
||||
{{ store.user_doors.length }} Türchen offen. Zeit bis zum nächsten
|
||||
Türchen:
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<progress v-else class="progress is-primary" max="100" />
|
||||
<CountDown :until="store.next_door_target" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import { advent22Store } from "@/lib/store";
|
||||
|
||||
import Calendar from "./Calendar.vue";
|
||||
import CountDown from "./CountDown.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
Calendar,
|
||||
CountDown,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
}
|
||||
const store = advent22Store();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,21 +2,19 @@
|
|||
<ConfigView />
|
||||
<CalendarAssistant />
|
||||
<DoorMapEditor />
|
||||
<BulmaDrawer header="Vorschau" :opening="store.update" refreshable>
|
||||
<UserView />
|
||||
</BulmaDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import { advent22Store } from "@/lib/store";
|
||||
|
||||
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";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
ConfigView,
|
||||
CalendarAssistant,
|
||||
DoorMapEditor,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {}
|
||||
const store = advent22Store();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<MultiModal @handle="modal_handle" />
|
||||
<MultiModal @handle="on_modal_handle" />
|
||||
|
||||
<BulmaDrawer header="Kalender-Assistent" @open="on_open" refreshable>
|
||||
<BulmaDrawer header="Kalender-Assistent" :opening="on_open" refreshable>
|
||||
<div class="card-content">
|
||||
<div class="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="fa-solid fa-door-open"
|
||||
:text="day"
|
||||
@click.left="door_click(day)"
|
||||
:icon="['fas', 'fa-door-open']"
|
||||
:text="day.toString()"
|
||||
@click.left="door_click(Number(day))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -45,72 +45,56 @@
|
|||
</BulmaDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { NumStrDict, objForEach } from "@/lib/api";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<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";
|
||||
|
||||
import MultiModal from "../MultiModal.vue";
|
||||
import MultiModal, { type HMultiModal } from "../MultiModal.vue";
|
||||
import BulmaButton from "../bulma/Button.vue";
|
||||
import BulmaDrawer from "../bulma/Drawer.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaButton,
|
||||
BulmaDrawer,
|
||||
MultiModal,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public day_data: {
|
||||
[day: number]: {
|
||||
part: string;
|
||||
image_name: string;
|
||||
};
|
||||
} = {};
|
||||
const day_data = ref<Record<number, { part: string; image_name: string }>>({});
|
||||
|
||||
private multi_modal?: MultiModal;
|
||||
let modal: HMultiModal | undefined;
|
||||
|
||||
public modal_handle(modal: MultiModal) {
|
||||
this.multi_modal = modal;
|
||||
}
|
||||
function on_modal_handle(handle: HMultiModal): void {
|
||||
modal = handle;
|
||||
}
|
||||
|
||||
public on_open(ready: () => void, fail: () => void): void {
|
||||
Promise.all([
|
||||
this.$advent22.api_get<NumStrDict>("admin/day_parts"),
|
||||
this.$advent22.api_get<NumStrDict>("admin/day_image_names"),
|
||||
])
|
||||
.then(([day_parts, day_image_names]) => {
|
||||
const _ensure_day_in_data = (day: number) => {
|
||||
if (!(day in this.day_data)) {
|
||||
this.day_data[day] = { part: "", image_name: "" };
|
||||
}
|
||||
};
|
||||
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"),
|
||||
]);
|
||||
|
||||
objForEach(day_parts, (day, part) => {
|
||||
_ensure_day_in_data(day);
|
||||
this.day_data[day].part = part;
|
||||
});
|
||||
const _ensure_day_in_data = (day: number) => {
|
||||
if (!(day in day_data.value)) {
|
||||
day_data.value[day] = { part: "", image_name: "" };
|
||||
}
|
||||
};
|
||||
|
||||
objForEach(day_image_names, (day, image_name) => {
|
||||
_ensure_day_in_data(day);
|
||||
this.day_data[day].image_name = image_name;
|
||||
});
|
||||
objForEach(day_parts, (day, part) => {
|
||||
_ensure_day_in_data(day);
|
||||
day_data.value[day].part = part;
|
||||
});
|
||||
|
||||
ready();
|
||||
})
|
||||
.catch(fail);
|
||||
}
|
||||
objForEach(day_image_names, (day, image_name) => {
|
||||
_ensure_day_in_data(day);
|
||||
day_data.value[day].image_name = image_name;
|
||||
});
|
||||
}
|
||||
|
||||
public door_click(day: number) {
|
||||
if (this.multi_modal === undefined) return;
|
||||
this.multi_modal.show_progress();
|
||||
async function door_click(day: number): Promise<void> {
|
||||
if (modal === undefined) return;
|
||||
modal.show_loading();
|
||||
|
||||
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());
|
||||
try {
|
||||
const day_image = await API.request<ImageData>(`user/image_${day}`);
|
||||
modal.show_image(day_image.data_url, name_door(day));
|
||||
} catch {
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<BulmaDrawer header="Konfiguration" @open="on_open" refreshable>
|
||||
<BulmaDrawer header="Konfiguration" :opening="on_open" refreshable>
|
||||
<div class="card-content">
|
||||
<div class="columns">
|
||||
<div class="column is-one-third">
|
||||
|
|
@ -139,12 +139,15 @@
|
|||
|
||||
<dt>Zugangsdaten</dt>
|
||||
<dd class="is-family-monospace">
|
||||
<BulmaSecret @load="load_dav_credentials">
|
||||
<BulmaSecret
|
||||
@show="load_credentials(creds.dav, 'admin/credentials/dav')"
|
||||
@hide="clear_credentials(creds.dav)"
|
||||
>
|
||||
<span class="tag is-danger">user</span>
|
||||
{{ dav_credentials[0] }}
|
||||
{{ creds.dav.username }}
|
||||
<br />
|
||||
<span class="tag is-danger">pass</span>
|
||||
{{ dav_credentials[1] }}
|
||||
{{ creds.dav.password }}
|
||||
</BulmaSecret>
|
||||
</dd>
|
||||
|
||||
|
|
@ -167,12 +170,15 @@
|
|||
|
||||
<dt>UI-Admin</dt>
|
||||
<dd class="is-family-monospace">
|
||||
<BulmaSecret @load="load_ui_credentials">
|
||||
<BulmaSecret
|
||||
@show="load_credentials(creds.ui, 'admin/credentials/ui')"
|
||||
@hide="clear_credentials(creds.ui)"
|
||||
>
|
||||
<span class="tag is-danger">user</span>
|
||||
{{ ui_credentials[0] }}
|
||||
{{ creds.ui.username }}
|
||||
<br />
|
||||
<span class="tag is-danger">pass</span>
|
||||
{{ ui_credentials[1] }}
|
||||
{{ creds.ui.password }}
|
||||
</BulmaSecret>
|
||||
</dd>
|
||||
</dl>
|
||||
|
|
@ -183,106 +189,108 @@
|
|||
</BulmaDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { AdminConfigModel, Credentials, DoorSaved } from "@/lib/api";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
<script setup lang="ts">
|
||||
import { API } from "@/lib/api";
|
||||
import type { AdminConfigModel, Credentials, DoorSaved } from "@/lib/model";
|
||||
import { advent22Store } from "@/lib/store";
|
||||
import { DateTime } from "luxon";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { ref } from "vue";
|
||||
|
||||
import BulmaDrawer from "../bulma/Drawer.vue";
|
||||
import BulmaSecret from "../bulma/Secret.vue";
|
||||
import CountDown from "../CountDown.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaDrawer,
|
||||
BulmaSecret,
|
||||
CountDown,
|
||||
const store = advent22Store();
|
||||
|
||||
const admin_config_model = ref<AdminConfigModel>({
|
||||
solution: {
|
||||
value: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
whitespace: "KEEP",
|
||||
special_chars: "KEEP",
|
||||
case: "KEEP",
|
||||
clean: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
puzzle: {
|
||||
first: "2023-12-01",
|
||||
next: "2023-12-01",
|
||||
last: "2023-12-24",
|
||||
end: "2024-04-01",
|
||||
seed: "",
|
||||
extra_days: [],
|
||||
skip_empty: true,
|
||||
},
|
||||
calendar: {
|
||||
config_file: "lorem ipsum",
|
||||
background: "dolor sit",
|
||||
favicon: "sit amet",
|
||||
},
|
||||
image: {
|
||||
size: 500,
|
||||
border: 0,
|
||||
},
|
||||
fonts: [{ file: "consetetur", size: 0 }],
|
||||
redis: {
|
||||
host: "0.0.0.0",
|
||||
port: 6379,
|
||||
db: 0,
|
||||
protocol: 3,
|
||||
},
|
||||
webdav: {
|
||||
url: "sadipscing elitr",
|
||||
cache_ttl: 0,
|
||||
config_file: "sed diam nonumy",
|
||||
},
|
||||
});
|
||||
|
||||
public admin_config_model: AdminConfigModel = {
|
||||
solution: {
|
||||
value: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
whitespace: "KEEP",
|
||||
special_chars: "KEEP",
|
||||
case: "KEEP",
|
||||
clean: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
},
|
||||
puzzle: {
|
||||
first: "2023-12-01",
|
||||
next: "2023-12-01",
|
||||
last: "2023-12-24",
|
||||
end: "2024-04-01",
|
||||
seed: "",
|
||||
extra_days: [],
|
||||
skip_empty: true,
|
||||
},
|
||||
calendar: {
|
||||
config_file: "lorem ipsum",
|
||||
background: "dolor sit",
|
||||
favicon: "sit amet",
|
||||
},
|
||||
image: {
|
||||
size: 500,
|
||||
border: 0,
|
||||
},
|
||||
fonts: [{ file: "consetetur", size: 0 }],
|
||||
redis: {
|
||||
host: "0.0.0.0",
|
||||
port: 6379,
|
||||
db: 0,
|
||||
protocol: 3,
|
||||
},
|
||||
webdav: {
|
||||
url: "sadipscing elitr",
|
||||
cache_ttl: 0,
|
||||
config_file: "sed diam nonumy",
|
||||
},
|
||||
};
|
||||
public doors: DoorSaved[] = [];
|
||||
public dav_credentials: Credentials = ["", ""];
|
||||
public ui_credentials: Credentials = ["", ""];
|
||||
const doors = ref<DoorSaved[]>([]);
|
||||
const creds = ref<Record<string, Credentials>>({
|
||||
dav: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
ui: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
public fmt_puzzle_date(name: keyof AdminConfigModel["puzzle"]): string {
|
||||
const iso_date = this.admin_config_model.puzzle[name];
|
||||
if (!(typeof iso_date == "string")) return "-";
|
||||
function fmt_puzzle_date(name: keyof AdminConfigModel["puzzle"]): string {
|
||||
const iso_date = admin_config_model.value.puzzle[name];
|
||||
if (!(typeof iso_date === "string")) return "-";
|
||||
|
||||
return DateTime.fromISO(iso_date).toLocaleString(DateTime.DATE_SHORT);
|
||||
}
|
||||
return DateTime.fromISO(iso_date).toLocaleString(DateTime.DATE_SHORT);
|
||||
}
|
||||
|
||||
public on_open(ready: () => void, fail: () => void): void {
|
||||
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
|
||||
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"),
|
||||
]);
|
||||
|
||||
this.admin_config_model = admin_config_model;
|
||||
this.doors = doors;
|
||||
void store_update; // discard value
|
||||
admin_config_model.value = new_admin_config_model;
|
||||
doors.value = new_doors;
|
||||
|
||||
ready();
|
||||
})
|
||||
.catch(fail);
|
||||
}
|
||||
clear_credentials(creds.value.dav);
|
||||
clear_credentials(creds.value.ui);
|
||||
}
|
||||
|
||||
public load_dav_credentials(): void {
|
||||
this.$advent22
|
||||
.api_get<Credentials>("admin/dav_credentials")
|
||||
.then((creds) => (this.dav_credentials = creds))
|
||||
.catch(() => {});
|
||||
}
|
||||
async function load_credentials(
|
||||
creds: Credentials,
|
||||
endpoint: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const new_creds = await API.request<Credentials>(endpoint);
|
||||
|
||||
public load_ui_credentials(): void {
|
||||
this.$advent22
|
||||
.api_get<Credentials>("admin/ui_credentials")
|
||||
.then((creds) => (this.ui_credentials = creds))
|
||||
.catch(() => {});
|
||||
}
|
||||
creds.username = new_creds.username;
|
||||
creds.password = new_creds.password;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function clear_credentials(creds: Credentials): void {
|
||||
creds.username = "";
|
||||
creds.password = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<BulmaDrawer header="Türchen bearbeiten" @open="on_open">
|
||||
<BulmaDrawer header="Türchen bearbeiten" :opening="load_doors">
|
||||
<nav class="level is-mobile mb-0" style="overflow-x: auto">
|
||||
<BulmaButton
|
||||
:disabled="current_step === 0"
|
||||
class="level-item is-link"
|
||||
@click="current_step--"
|
||||
icon="fa-solid fa-backward"
|
||||
:icon="['fas', 'fa-backward']"
|
||||
/>
|
||||
|
||||
<BulmaBreadcrumbs
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
:disabled="current_step === 2"
|
||||
class="level-item is-link"
|
||||
@click="current_step++"
|
||||
icon="fa-solid fa-forward"
|
||||
:icon="['fas', 'fa-forward']"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
|
|
@ -37,8 +37,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<DoorPlacer v-if="current_step === 0" :doors="doors" />
|
||||
<DoorChooser v-if="current_step === 1" :doors="doors" />
|
||||
<DoorPlacer v-if="current_step === 0" v-model="doors" />
|
||||
<DoorChooser v-if="current_step === 1" v-model="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="fa-solid fa-cloud-arrow-down"
|
||||
:icon="['fas', 'fa-cloud-arrow-down']"
|
||||
:busy="loading_doors"
|
||||
text="Laden"
|
||||
/>
|
||||
<BulmaButton
|
||||
class="card-footer-item is-warning"
|
||||
@click="on_discard"
|
||||
icon="fa-solid fa-trash"
|
||||
:icon="['fas', 'fa-trash']"
|
||||
text="Löschen"
|
||||
/>
|
||||
<BulmaButton
|
||||
class="card-footer-item is-success"
|
||||
@click="on_upload"
|
||||
icon="fa-solid fa-cloud-arrow-up"
|
||||
:icon="['fas', 'fa-cloud-arrow-up']"
|
||||
:busy="saving_doors"
|
||||
text="Speichern"
|
||||
/>
|
||||
|
|
@ -68,127 +68,106 @@
|
|||
</BulmaDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { DoorSaved } from "@/lib/api";
|
||||
import { Door } from "@/lib/door";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
<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";
|
||||
|
||||
import Calendar from "../Calendar.vue";
|
||||
import BulmaBreadcrumbs, { Step } from "../bulma/Breadcrumbs.vue";
|
||||
import BulmaBreadcrumbs from "../bulma/Breadcrumbs.vue";
|
||||
import BulmaButton from "../bulma/Button.vue";
|
||||
import BulmaDrawer from "../bulma/Drawer.vue";
|
||||
import DoorChooser from "../editor/DoorChooser.vue";
|
||||
import DoorPlacer from "../editor/DoorPlacer.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaBreadcrumbs,
|
||||
BulmaButton,
|
||||
BulmaDrawer,
|
||||
DoorPlacer,
|
||||
DoorChooser,
|
||||
Calendar,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly steps: Step[] = [
|
||||
{ label: "Platzieren", icon: "fa-solid fa-crosshairs" },
|
||||
{ label: "Ordnen", icon: "fa-solid fa-list-ol" },
|
||||
{ label: "Vorschau", icon: "fa-solid fa-magnifying-glass" },
|
||||
];
|
||||
public current_step = 0;
|
||||
public doors: Door[] = [];
|
||||
private readonly store = advent22Store();
|
||||
const steps: BCStep[] = [
|
||||
{ label: "Platzieren", icon: ["fas", "fa-crosshairs"] },
|
||||
{ label: "Ordnen", icon: ["fas", "fa-list-ol"] },
|
||||
{ label: "Vorschau", icon: ["fas", "fa-magnifying-glass"] },
|
||||
];
|
||||
|
||||
public loading_doors = false;
|
||||
public saving_doors = false;
|
||||
const doors = ref<Door[]>([]);
|
||||
const current_step = ref(0);
|
||||
const loading_doors = ref(false);
|
||||
const saving_doors = ref(false);
|
||||
|
||||
private load_doors(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.$advent22
|
||||
.api_get<DoorSaved[]>("admin/doors")
|
||||
.then((data) => {
|
||||
this.doors.length = 0;
|
||||
async function load_doors(): Promise<void> {
|
||||
try {
|
||||
const data = await API.request<DoorSaved[]>("admin/doors");
|
||||
|
||||
for (const value of data) {
|
||||
this.doors.push(Door.load(value));
|
||||
}
|
||||
doors.value.length = 0;
|
||||
for (const value of data) {
|
||||
doors.value.push(Door.load(value));
|
||||
}
|
||||
} catch (error) {
|
||||
APIError.alert(error);
|
||||
throw null;
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
this.store.alert_user_error(error);
|
||||
reject();
|
||||
});
|
||||
async function save_doors(): Promise<void> {
|
||||
try {
|
||||
const data: DoorSaved[] = [];
|
||||
|
||||
for (const door of doors.value) {
|
||||
data.push(door.save());
|
||||
}
|
||||
|
||||
await API.request<void>({
|
||||
endpoint: "admin/doors",
|
||||
method: "PUT",
|
||||
data: data,
|
||||
});
|
||||
} catch (error) {
|
||||
APIError.alert(error);
|
||||
throw null;
|
||||
}
|
||||
}
|
||||
|
||||
private save_doors(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const data: DoorSaved[] = [];
|
||||
async function on_download(): Promise<void> {
|
||||
if (confirm("Aktuelle Änderungen verwerfen und Status vom Server laden?")) {
|
||||
loading_doors.value = true;
|
||||
|
||||
for (const door of this.doors) {
|
||||
data.push(door.save());
|
||||
}
|
||||
try {
|
||||
load_doors();
|
||||
|
||||
this.$advent22
|
||||
.api_put("admin/doors", data)
|
||||
.then(resolve)
|
||||
.catch((error) => {
|
||||
this.store.alert_user_error(error);
|
||||
reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public on_open(ready: () => void, fail: () => void): void {
|
||||
this.load_doors().then(ready).catch(fail);
|
||||
}
|
||||
|
||||
public on_download() {
|
||||
if (confirm("Aktuelle Änderungen verwerfen und Status vom Server laden?")) {
|
||||
this.loading_doors = true;
|
||||
|
||||
this.load_doors()
|
||||
.then(() =>
|
||||
toast({
|
||||
message: "Erfolgreich!",
|
||||
type: "is-success",
|
||||
duration: 2e3,
|
||||
}),
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => (this.loading_doors = false));
|
||||
toast({
|
||||
message: "Erfolgreich!",
|
||||
type: "is-success",
|
||||
duration: 2e3,
|
||||
});
|
||||
} finally {
|
||||
loading_doors.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public on_discard() {
|
||||
if (confirm("Alle Türchen löschen? (nur lokal)")) {
|
||||
// empty `doors` array
|
||||
this.doors.length = 0;
|
||||
}
|
||||
function on_discard(): void {
|
||||
if (confirm("Alle Türchen löschen? (nur lokal)")) {
|
||||
// empty `doors` array
|
||||
doors.value.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public on_upload() {
|
||||
if (confirm("Aktuelle Änderungen an den Server schicken?")) {
|
||||
this.saving_doors = true;
|
||||
async function on_upload(): Promise<void> {
|
||||
if (confirm("Aktuelle Änderungen an den Server schicken?")) {
|
||||
saving_doors.value = true;
|
||||
|
||||
this.save_doors()
|
||||
.then(() => {
|
||||
this.load_doors()
|
||||
.then(() =>
|
||||
toast({
|
||||
message: "Erfolgreich!",
|
||||
type: "is-success",
|
||||
duration: 2e3,
|
||||
}),
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => (this.saving_doors = false));
|
||||
})
|
||||
.catch(() => (this.saving_doors = false));
|
||||
try {
|
||||
save_doors();
|
||||
load_doors();
|
||||
|
||||
toast({
|
||||
message: "Erfolgreich!",
|
||||
type: "is-success",
|
||||
duration: 2e3,
|
||||
});
|
||||
} finally {
|
||||
saving_doors.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
<!-- eslint-disable vue/multi-word-component-names -->
|
||||
<template>
|
||||
<nav class="breadcrumb has-succeeds-separator">
|
||||
<ul>
|
||||
<li
|
||||
v-for="(step, index) in steps"
|
||||
:key="`step-${index}`"
|
||||
:class="modelValue === index ? 'is-active' : ''"
|
||||
@click.left="change_step(index)"
|
||||
:key="index"
|
||||
:class="model === index ? 'is-active' : ''"
|
||||
@click.left="model = index"
|
||||
>
|
||||
<a>
|
||||
<a :class="model === index ? 'has-text-primary' : ''">
|
||||
<span class="icon is-small">
|
||||
<font-awesome-icon :icon="step.icon" />
|
||||
<FontAwesomeIcon :icon="step.icon" />
|
||||
</span>
|
||||
<span>{{ step.label }}</span>
|
||||
</a>
|
||||
|
|
@ -18,31 +19,15 @@
|
|||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
export interface Step {
|
||||
<script setup lang="ts">
|
||||
export interface BCStep {
|
||||
label: string;
|
||||
icon: string;
|
||||
icon: string | string[];
|
||||
}
|
||||
|
||||
@Options({
|
||||
props: {
|
||||
steps: Array,
|
||||
modelValue: Number,
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
})
|
||||
export default class extends Vue {
|
||||
public steps!: Step[];
|
||||
public modelValue!: number;
|
||||
const model = defineModel<number>({ required: true });
|
||||
|
||||
public change_step(next_step: number) {
|
||||
if (next_step === this.modelValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("update:modelValue", next_step);
|
||||
}
|
||||
}
|
||||
defineProps<{
|
||||
steps: BCStep[];
|
||||
}>();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,28 @@
|
|||
<!-- eslint-disable vue/multi-word-component-names -->
|
||||
<template>
|
||||
<button class="button">
|
||||
<slot v-if="text === undefined" name="default">
|
||||
<font-awesome-icon
|
||||
v-if="icon !== undefined"
|
||||
:icon="icon"
|
||||
:beat-fade="busy"
|
||||
/>
|
||||
</slot>
|
||||
<template v-else>
|
||||
<slot name="default">
|
||||
<span v-if="icon !== undefined" class="icon">
|
||||
<slot name="default">
|
||||
<font-awesome-icon :icon="icon" :beat-fade="busy" />
|
||||
</slot>
|
||||
<FontAwesomeIcon
|
||||
v-if="icon !== undefined"
|
||||
:icon="icon"
|
||||
:beat-fade="busy"
|
||||
/>
|
||||
</span>
|
||||
<span>{{ text }}</span>
|
||||
</template>
|
||||
</slot>
|
||||
<span v-if="text !== undefined">{{ text }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
@Options({
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
busy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon?: string | string[];
|
||||
text?: string;
|
||||
busy?: boolean;
|
||||
}>(),
|
||||
{
|
||||
busy: false,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public icon?: string;
|
||||
public text?: string;
|
||||
public busy!: boolean;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,99 +1,87 @@
|
|||
<!-- eslint-disable vue/multi-word-component-names -->
|
||||
<template>
|
||||
<div class="card">
|
||||
<header class="card-header is-unselectable" style="cursor: pointer">
|
||||
<p class="card-header-title" @click="toggle">{{ header }}</p>
|
||||
|
||||
<p v-if="refreshable" class="card-header-icon px-0">
|
||||
<BulmaButton class="tag icon is-primary" @click="refresh">
|
||||
<font-awesome-icon
|
||||
icon="fa-solid fa-arrows-rotate"
|
||||
:spin="is_open && loading"
|
||||
<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'"
|
||||
/>
|
||||
</BulmaButton>
|
||||
</p>
|
||||
|
||||
<button class="card-header-icon" @click="toggle">
|
||||
<span class="icon">
|
||||
<font-awesome-icon
|
||||
:icon="'fa-solid fa-angle-' + (is_open ? 'down' : 'right')"
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', is_open ? 'angle-down' : 'angle-right']"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<template v-if="is_open">
|
||||
<div v-if="loading" class="card-content">
|
||||
<progress class="progress is-primary" max="100" />
|
||||
<slot v-if="state === 'loading'" name="loading">
|
||||
<div class="card-content">
|
||||
<progress class="progress is-primary" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="failed"
|
||||
class="card-content has-text-danger has-text-centered"
|
||||
>
|
||||
</slot>
|
||||
|
||||
<slot v-else-if="state === 'err'" name="error">
|
||||
<div class="card-content has-text-danger has-text-centered">
|
||||
<span class="icon is-large">
|
||||
<font-awesome-icon icon="fa-solid fa-ban" size="3x" />
|
||||
<FontAwesomeIcon :icon="['fas', 'ban']" size="3x" />
|
||||
</span>
|
||||
</div>
|
||||
<slot v-else name="default" />
|
||||
</template>
|
||||
</slot>
|
||||
|
||||
<slot v-else-if="state === 'ok'" name="default" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import BulmaButton from "./Button.vue";
|
||||
|
||||
enum DrawerState {
|
||||
Loading,
|
||||
Ready,
|
||||
Failed,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaButton,
|
||||
},
|
||||
props: {
|
||||
header: String,
|
||||
refreshable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["open"],
|
||||
})
|
||||
export default class extends Vue {
|
||||
public header!: string;
|
||||
public refreshable!: boolean;
|
||||
async function load(): Promise<void> {
|
||||
state.value = "loading";
|
||||
|
||||
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;
|
||||
try {
|
||||
await props.opening();
|
||||
state.value = "ok";
|
||||
} catch {
|
||||
state.value = "err";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,68 +1,52 @@
|
|||
<!-- eslint-disable vue/multi-word-component-names -->
|
||||
<template>
|
||||
<slot v-if="show" name="default" />
|
||||
<slot v-if="state === 'visible'" name="default" />
|
||||
<span v-else>***</span>
|
||||
<BulmaButton
|
||||
:class="`tag icon is-${button_class} ml-2`"
|
||||
:icon="`fa-solid fa-${button_icon}`"
|
||||
:busy="busy"
|
||||
:class="`is-small is-${record.color} ml-2`"
|
||||
:icon="['fas', record.icon]"
|
||||
:busy="state === 'pending'"
|
||||
@click="on_click"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import BulmaButton from "./Button.vue";
|
||||
|
||||
enum ClickState {
|
||||
Green = 0,
|
||||
Yellow = 1,
|
||||
Red = 2,
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
(event: "show"): void;
|
||||
(event: "hide"): void;
|
||||
}>();
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaButton,
|
||||
},
|
||||
emits: ["load"],
|
||||
})
|
||||
export default class extends Vue {
|
||||
public state = ClickState.Green;
|
||||
type State = "hidden" | "pending" | "visible";
|
||||
const state = ref<State>("hidden");
|
||||
|
||||
public on_click(): void {
|
||||
this.state++;
|
||||
this.state %= 3;
|
||||
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);
|
||||
|
||||
if (this.state === ClickState.Red) {
|
||||
this.$emit("load");
|
||||
}
|
||||
let pending_timeout: number | undefined;
|
||||
|
||||
function on_click(): void {
|
||||
state.value = record.value.next;
|
||||
|
||||
if (state.value === "hidden") {
|
||||
emit("hide");
|
||||
}
|
||||
|
||||
public get show(): boolean {
|
||||
return this.state === ClickState.Red;
|
||||
if (state.value === "pending") {
|
||||
pending_timeout = window.setTimeout(() => (state.value = "hidden"), 2500);
|
||||
} else {
|
||||
window.clearTimeout(pending_timeout);
|
||||
}
|
||||
|
||||
public get busy(): boolean {
|
||||
return this.state === ClickState.Yellow;
|
||||
}
|
||||
|
||||
public get button_class(): string {
|
||||
switch (this.state) {
|
||||
case ClickState.Red:
|
||||
return "danger";
|
||||
case ClickState.Yellow:
|
||||
return "warning";
|
||||
default:
|
||||
return "primary";
|
||||
}
|
||||
}
|
||||
|
||||
public get button_icon(): string {
|
||||
if (this.state === ClickState.Red) {
|
||||
return "eye-slash";
|
||||
} else {
|
||||
return "eye";
|
||||
}
|
||||
if (state.value === "visible") {
|
||||
emit("show");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
<!-- eslint-disable vue/multi-word-component-names -->
|
||||
<template>
|
||||
<div style="display: none">
|
||||
<div v-bind="$attrs" ref="message">
|
||||
|
|
@ -6,38 +7,41 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import * as bulmaToast from "bulma-toast";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import { type Options as ToastOptions, toast } from "bulma-toast";
|
||||
import { onMounted, useTemplateRef } from "vue";
|
||||
|
||||
@Options({
|
||||
emits: ["handle"],
|
||||
})
|
||||
export default class extends Vue {
|
||||
public created(): void {
|
||||
this.$emit("handle", this);
|
||||
}
|
||||
export type HBulmaToast = {
|
||||
show(options: ToastOptions): void;
|
||||
hide(): void;
|
||||
};
|
||||
|
||||
public show(options: bulmaToast.Options = {}) {
|
||||
if (!(this.$refs.message instanceof HTMLElement)) return;
|
||||
const emit = defineEmits<{
|
||||
(event: "handle", handle: HBulmaToast): void;
|
||||
}>();
|
||||
|
||||
bulmaToast.toast({
|
||||
...options,
|
||||
single: true,
|
||||
message: this.$refs.message,
|
||||
});
|
||||
}
|
||||
const message_div = useTemplateRef("message");
|
||||
|
||||
public hide() {
|
||||
if (!(this.$refs.message instanceof HTMLElement)) return;
|
||||
onMounted(() =>
|
||||
emit("handle", {
|
||||
show(options: ToastOptions = {}): void {
|
||||
if (message_div.value === null) return;
|
||||
|
||||
const toast_div = this.$refs.message.parentElement;
|
||||
if (!(toast_div instanceof HTMLDivElement)) return;
|
||||
toast({
|
||||
...options,
|
||||
single: true,
|
||||
message: message_div.value,
|
||||
});
|
||||
},
|
||||
hide(): void {
|
||||
// using "toast" detaches "message" from the invisible "div"
|
||||
// => toast_div is not part of this component!
|
||||
const toast_div = message_div.value?.parentElement;
|
||||
const delete_button = toast_div?.querySelector("button.delete");
|
||||
if (!(delete_button instanceof HTMLButtonElement)) return;
|
||||
|
||||
const dbutton = toast_div.querySelector("button.delete");
|
||||
if (!(dbutton instanceof HTMLButtonElement)) return;
|
||||
|
||||
dbutton.click();
|
||||
}
|
||||
}
|
||||
delete_button.click();
|
||||
},
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -13,29 +13,22 @@
|
|||
</SVGRect>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Door } from "@/lib/door";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import { Door } from "@/lib/rects/door";
|
||||
import { advent22Store } from "@/lib/store";
|
||||
|
||||
import type { VueLike } from "@/lib/helpers";
|
||||
import SVGRect from "./SVGRect.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
SVGRect,
|
||||
},
|
||||
props: {
|
||||
door: Door,
|
||||
force_visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
const store = advent22Store();
|
||||
|
||||
public door!: Door;
|
||||
public force_visible!: boolean;
|
||||
}
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
door: VueLike<Door>;
|
||||
force_visible?: boolean;
|
||||
}>(),
|
||||
{
|
||||
force_visible: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,31 @@
|
|||
<template>
|
||||
<foreignObject
|
||||
:x="Math.round(store.calendar_aspect_ratio * rectangle.left)"
|
||||
:x="Math.round(aspect_ratio * rectangle.left)"
|
||||
:y="rectangle.top"
|
||||
:width="Math.round(store.calendar_aspect_ratio * rectangle.width)"
|
||||
:width="Math.round(aspect_ratio * rectangle.width)"
|
||||
:height="rectangle.height"
|
||||
:style="`transform: scaleX(${1 / store.calendar_aspect_ratio})`"
|
||||
:style="`transform: scaleX(${1 / aspect_ratio})`"
|
||||
>
|
||||
<div
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
:class="`px-2 is-flex is-align-items-center is-justify-content-center is-size-2 has-text-weight-bold ${extra_classes}`"
|
||||
:class="`px-2 is-flex is-align-items-center is-justify-content-center is-size-2 has-text-weight-bold ${variant} ${
|
||||
visible ? 'visible' : ''
|
||||
}`"
|
||||
style="height: inherit"
|
||||
:title="title"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</foreignObject>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Rectangle } from "@/lib/rectangle";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<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();
|
||||
|
||||
type BulmaVariant =
|
||||
| "primary"
|
||||
|
|
@ -30,40 +35,28 @@ type BulmaVariant =
|
|||
| "warning"
|
||||
| "danger";
|
||||
|
||||
@Options({
|
||||
props: {
|
||||
variant: String,
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rectangle: Rectangle,
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant: BulmaVariant;
|
||||
visible?: boolean;
|
||||
rectangle: VueLike<Rectangle>;
|
||||
}>(),
|
||||
{
|
||||
visible: true,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
);
|
||||
|
||||
private variant!: BulmaVariant;
|
||||
private visible!: boolean;
|
||||
public rectangle!: Rectangle;
|
||||
public title?: string;
|
||||
|
||||
public get extra_classes(): string {
|
||||
let result = this.variant;
|
||||
|
||||
if (this.visible) result += " visible";
|
||||
|
||||
return result;
|
||||
const aspect_ratio = computed(() => {
|
||||
try {
|
||||
return unwrap_loading(store.background_image).aspect_ratio;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/bulma-scheme";
|
||||
@use "@/bulma-scheme" as scheme;
|
||||
|
||||
foreignObject > div {
|
||||
&:not(.visible, :hover):deep() > * {
|
||||
|
|
@ -75,7 +68,7 @@ foreignObject > div {
|
|||
border-width: 2px;
|
||||
border-style: solid;
|
||||
|
||||
@each $name, $color in $advent22-colors {
|
||||
@each $name, $color in scheme.$colors {
|
||||
&.#{$name} {
|
||||
background-color: rgba($color, 0.3);
|
||||
border-color: rgba($color, 0.9);
|
||||
|
|
|
|||
|
|
@ -15,10 +15,8 @@
|
|||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vector2D } from "@/lib/vector2d";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import { Vector2D } from "@/lib/rects/vector2d";
|
||||
|
||||
function get_event_thous(event: MouseEvent): Vector2D {
|
||||
if (!(event.currentTarget instanceof SVGSVGElement)) {
|
||||
|
|
@ -31,45 +29,23 @@ function get_event_thous(event: MouseEvent): Vector2D {
|
|||
);
|
||||
}
|
||||
|
||||
function mouse_event_validator(event: object, point: object): boolean {
|
||||
if (!(event instanceof MouseEvent)) {
|
||||
console.warn(event, "is not a MouseEvent!");
|
||||
return false;
|
||||
}
|
||||
type TCEventType = "mousedown" | "mousemove" | "mouseup" | "click" | "dblclick";
|
||||
|
||||
if (!(point instanceof Vector2D)) {
|
||||
console.warn(point, "is not a Vector2D!");
|
||||
return false;
|
||||
}
|
||||
const is_tceventtype = (t: unknown): t is TCEventType =>
|
||||
t === "mousedown" ||
|
||||
t === "mousemove" ||
|
||||
t === "mouseup" ||
|
||||
t === "click" ||
|
||||
t === "dblclick";
|
||||
|
||||
return true;
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
(event: TCEventType, e: MouseEvent, point: Vector2D): void;
|
||||
}>();
|
||||
|
||||
@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();
|
||||
function transform_mouse_event(event: MouseEvent): void {
|
||||
if (!is_tceventtype(event.type)) return;
|
||||
|
||||
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);
|
||||
}
|
||||
emit(event.type, event, get_event_thous(event));
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
@dblclick.left="remove_rect"
|
||||
>
|
||||
<CalendarDoor
|
||||
v-for="(door, index) in doors"
|
||||
v-for="(door, index) in model"
|
||||
:key="`door-${index}`"
|
||||
:door="door"
|
||||
force_visible
|
||||
|
|
@ -17,132 +17,94 @@
|
|||
<SVGRect
|
||||
v-if="preview_visible"
|
||||
variant="success"
|
||||
:rectangle="preview_rect"
|
||||
:rectangle="preview"
|
||||
visible
|
||||
/>
|
||||
</ThouCanvas>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Door } from "@/lib/door";
|
||||
import { Rectangle } from "@/lib/rectangle";
|
||||
import { Vector2D } from "@/lib/vector2d";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<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";
|
||||
|
||||
import type { VueLike } from "@/lib/helpers";
|
||||
import CalendarDoor from "../calendar/CalendarDoor.vue";
|
||||
import SVGRect from "../calendar/SVGRect.vue";
|
||||
import ThouCanvas from "../calendar/ThouCanvas.vue";
|
||||
|
||||
enum CanvasState {
|
||||
Idle,
|
||||
Drawing,
|
||||
Dragging,
|
||||
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];
|
||||
}
|
||||
|
||||
@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[];
|
||||
function draw_start(event: MouseEvent, point: Vector2D): void {
|
||||
if (preview_visible.value) return;
|
||||
|
||||
public get preview_visible(): boolean {
|
||||
return this.state !== CanvasState.Idle;
|
||||
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));
|
||||
}
|
||||
|
||||
private pop_door(point: Vector2D): Door | undefined {
|
||||
const idx = this.doors.findIndex((rect) => rect.position.contains(point));
|
||||
state.value = { kind: "idle" };
|
||||
}
|
||||
|
||||
if (idx === -1) {
|
||||
return;
|
||||
}
|
||||
function drag_start(event: MouseEvent, point: Vector2D): void {
|
||||
if (preview_visible.value) return;
|
||||
|
||||
return this.doors.splice(idx, 1)[0];
|
||||
const drag_door = pop_door(point);
|
||||
|
||||
if (drag_door === undefined) return;
|
||||
|
||||
preview.value = drag_door.position;
|
||||
|
||||
state.value = { kind: "dragging", door: drag_door, origin: point };
|
||||
}
|
||||
|
||||
function drag_finish(): void {
|
||||
if (state.value.kind !== "dragging") return;
|
||||
|
||||
model.value.push(new Door(preview.value, state.value.door.day));
|
||||
|
||||
state.value = { kind: "idle" };
|
||||
}
|
||||
|
||||
function on_mousemove(event: MouseEvent, point: Vector2D): void {
|
||||
if (state.value.kind === "drawing") {
|
||||
preview.value = preview.value.update(undefined, point);
|
||||
} else if (state.value.kind === "dragging") {
|
||||
const movement = point.minus(state.value.origin);
|
||||
preview.value = state.value.door.position.move(movement);
|
||||
}
|
||||
}
|
||||
|
||||
public draw_start(event: MouseEvent, point: Vector2D) {
|
||||
if (this.preview_visible) {
|
||||
return;
|
||||
}
|
||||
function remove_rect(event: MouseEvent, point: Vector2D): void {
|
||||
if (preview_visible.value) 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);
|
||||
}
|
||||
pop_door(point);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,37 +11,26 @@
|
|||
</ul>
|
||||
</div>
|
||||
<figure class="image is-unselectable">
|
||||
<img :src="store.calendar_background_image" />
|
||||
<img :src="unwrap_loading(store.background_image).data_url" />
|
||||
<ThouCanvas>
|
||||
<PreviewDoor
|
||||
v-for="(door, index) in doors"
|
||||
v-for="(_, index) in model"
|
||||
:key="`door-${index}`"
|
||||
:door="door"
|
||||
v-model="model[index]"
|
||||
/>
|
||||
</ThouCanvas>
|
||||
</figure>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Door } from "@/lib/door";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import { type VueLike, unwrap_loading } from "@/lib/helpers";
|
||||
import { Door } from "@/lib/rects/door";
|
||||
import { advent22Store } from "@/lib/store";
|
||||
|
||||
import ThouCanvas from "../calendar/ThouCanvas.vue";
|
||||
import PreviewDoor from "./PreviewDoor.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
ThouCanvas,
|
||||
PreviewDoor,
|
||||
},
|
||||
props: {
|
||||
doors: Array,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public doors!: Door[];
|
||||
public readonly store = advent22Store();
|
||||
}
|
||||
const model = defineModel<VueLike<Door>[]>({ required: true });
|
||||
const store = advent22Store();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -9,29 +9,19 @@
|
|||
</ul>
|
||||
</div>
|
||||
<figure class="image is-unselectable">
|
||||
<img :src="store.calendar_background_image" />
|
||||
<DoorCanvas :doors="doors" />
|
||||
<img :src="unwrap_loading(store.background_image).data_url" />
|
||||
<DoorCanvas v-model="model" />
|
||||
</figure>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Door } from "@/lib/door";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import { type VueLike, unwrap_loading } from "@/lib/helpers";
|
||||
import { Door } from "@/lib/rects/door";
|
||||
import { advent22Store } from "@/lib/store";
|
||||
|
||||
import DoorCanvas from "./DoorCanvas.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
DoorCanvas,
|
||||
},
|
||||
props: {
|
||||
doors: Array,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public doors!: Door[];
|
||||
public readonly store = advent22Store();
|
||||
}
|
||||
const model = defineModel<VueLike<Door>[]>({ required: true });
|
||||
const store = advent22Store();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<SVGRect
|
||||
style="cursor: text"
|
||||
:rectangle="door.position"
|
||||
:rectangle="model.position"
|
||||
:variant="editing ? 'success' : 'primary'"
|
||||
@click.left="on_click"
|
||||
@click.left.stop="on_click"
|
||||
visible
|
||||
>
|
||||
<input
|
||||
|
|
@ -12,78 +12,60 @@
|
|||
ref="day_input"
|
||||
class="input is-large"
|
||||
type="number"
|
||||
:min="MIN_DAY"
|
||||
:min="Door.MIN_DAY"
|
||||
placeholder="Tag"
|
||||
@keydown="on_keydown"
|
||||
/>
|
||||
<div v-else class="has-text-danger">
|
||||
{{ door.day > 0 ? door.day : "*" }}
|
||||
{{ model.day > 0 ? model.day : "*" }}
|
||||
</div>
|
||||
</SVGRect>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Door } from "@/lib/door";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import { Door } from "@/lib/rects/door";
|
||||
import { ref, useTemplateRef } from "vue";
|
||||
|
||||
import { type VueLike, unwrap_vuelike, wait_for } from "@/lib/helpers";
|
||||
import SVGRect from "../calendar/SVGRect.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
SVGRect,
|
||||
},
|
||||
props: {
|
||||
door: Door,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public door!: Door;
|
||||
public readonly MIN_DAY = Door.MIN_DAY;
|
||||
const model = defineModel<VueLike<Door>>({ required: true });
|
||||
const day_input = useTemplateRef("day_input");
|
||||
|
||||
public day_str = "";
|
||||
public editing = false;
|
||||
const day_str = ref("");
|
||||
const editing = ref(false);
|
||||
|
||||
private toggle_editing() {
|
||||
this.day_str = String(this.door.day);
|
||||
this.editing = !this.editing;
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
public on_click(event: MouseEvent) {
|
||||
if (!(event.target instanceof HTMLDivElement)) {
|
||||
return;
|
||||
}
|
||||
toggle_editing();
|
||||
}
|
||||
|
||||
if (!this.editing) {
|
||||
const day_input_focus = () => {
|
||||
if (this.$refs.day_input instanceof HTMLInputElement) {
|
||||
this.$refs.day_input.select();
|
||||
return;
|
||||
}
|
||||
function on_keydown(event: KeyboardEvent): void {
|
||||
if (!editing.value) return;
|
||||
|
||||
this.$nextTick(day_input_focus);
|
||||
};
|
||||
day_input_focus();
|
||||
} else {
|
||||
this.door.day = this.day_str;
|
||||
}
|
||||
|
||||
this.toggle_editing();
|
||||
}
|
||||
|
||||
public on_keydown(event: KeyboardEvent) {
|
||||
if (!this.editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
this.door.day = this.day_str;
|
||||
this.toggle_editing();
|
||||
} else if (event.key === "Delete") {
|
||||
this.door.day = -1;
|
||||
this.toggle_editing();
|
||||
} else if (event.key === "Escape") {
|
||||
this.toggle_editing();
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
unwrap_vuelike(model.value).day = day_str.value;
|
||||
toggle_editing();
|
||||
} else if (event.key === "Delete") {
|
||||
model.value.day = -1;
|
||||
toggle_editing();
|
||||
} else if (event.key === "Escape") {
|
||||
toggle_editing();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
10
ui/src/d.ts/shims-advent22.d.ts
vendored
10
ui/src/d.ts/shims-advent22.d.ts
vendored
|
|
@ -1,10 +0,0 @@
|
|||
import { Advent22 } from "@/plugins/advent22";
|
||||
|
||||
declare module "@vue/runtime-core" {
|
||||
// bind to `this` keyword
|
||||
interface ComponentCustomProperties {
|
||||
$advent22: Advent22;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
@ -1,71 +1,92 @@
|
|||
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;
|
||||
};
|
||||
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 interface SiteConfigModel {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
content: string;
|
||||
footer: string;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
export interface NumStrDict {
|
||||
[key: number]: string;
|
||||
}
|
||||
// in development mode, return "proto://hostname:8000/api"
|
||||
return `${window.location.protocol}//${window.location.hostname}:8000/api`;
|
||||
}
|
||||
|
||||
export interface DoorSaved {
|
||||
day: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
private static readonly axios = axios.create({
|
||||
timeout: 10e3,
|
||||
baseURL: this.api_baseurl,
|
||||
});
|
||||
|
||||
export type Credentials = [username: string, password: string];
|
||||
private static readonly creds_key = "advent22/credentials";
|
||||
|
||||
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]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
public static async request<T = string>(p: Params): Promise<T>;
|
||||
public static async request<T = string>(p: string): Promise<T>;
|
||||
public static async request<T = string>(p: Params | string): Promise<T> {
|
||||
if (typeof p === "string") p = { endpoint: p };
|
||||
|
||||
try {
|
||||
const response = await this.axios.request<T>(this.get_axios_config(p));
|
||||
return response.data;
|
||||
} catch (reason) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to query ${p.endpoint}: ${reason}`);
|
||||
throw new APIError(reason, p.endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
75
ui/src/lib/api_error.ts
Normal file
75
ui/src/lib/api_error.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { AxiosError } from "axios";
|
||||
import { toast } from "bulma-toast";
|
||||
|
||||
export class APIError extends Error {
|
||||
axios_error?: AxiosError;
|
||||
|
||||
constructor(reason: unknown, endpoint: string) {
|
||||
super(endpoint); // sets this.message to the endpoint
|
||||
Object.setPrototypeOf(this, APIError.prototype);
|
||||
|
||||
if (reason instanceof AxiosError) {
|
||||
this.axios_error = reason;
|
||||
}
|
||||
}
|
||||
|
||||
public format(): string {
|
||||
let msg =
|
||||
"Unbekannter Fehler, bitte wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
|
||||
let code = "U";
|
||||
const result = () => `${msg} (Fehlercode: ${code}/${this.message})`;
|
||||
|
||||
if (this.axios_error === undefined) return result();
|
||||
|
||||
switch (this.axios_error.code) {
|
||||
case "ECONNABORTED":
|
||||
// API unerreichbar
|
||||
msg =
|
||||
"API antwortet nicht, bitte später wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
|
||||
code = "D";
|
||||
break;
|
||||
|
||||
case "ERR_NETWORK":
|
||||
// Netzwerk nicht verbunden
|
||||
msg = "Sieht aus, als sei deine Netzwerkverbindung gestört.";
|
||||
code = "N";
|
||||
break;
|
||||
|
||||
default:
|
||||
if (this.axios_error.response === undefined) return result();
|
||||
|
||||
switch (this.axios_error.response.status) {
|
||||
case 401:
|
||||
// UNAUTHORIZED
|
||||
msg = "Netter Versuch :)";
|
||||
code = "A";
|
||||
break;
|
||||
|
||||
case 422:
|
||||
// UNPROCESSABLE ENTITY
|
||||
msg = "Funktion ist kaputt, bitte Admin benachrichtigen!";
|
||||
code = "I";
|
||||
break;
|
||||
|
||||
default:
|
||||
// HTTP
|
||||
code = `H${this.axios_error.response.status}`;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return result();
|
||||
}
|
||||
|
||||
public alert(): void {
|
||||
toast({
|
||||
message: this.format(),
|
||||
type: "is-danger",
|
||||
});
|
||||
}
|
||||
|
||||
public static alert(error: unknown): void {
|
||||
new APIError(error, "").alert();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,14 @@
|
|||
import { App, Plugin } from "vue";
|
||||
/* import font awesome icon component */
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
|
||||
/* import the fontawesome core */
|
||||
import { 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, fab);
|
||||
library.add(fas);
|
||||
|
||||
/* import font awesome icon component */
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
|
||||
export const FontAwesomePlugin: Plugin = {
|
||||
install(app: App) {
|
||||
app.component("font-awesome-icon", FontAwesomeIcon);
|
||||
},
|
||||
};
|
||||
export default FontAwesomeIcon;
|
||||
52
ui/src/lib/helpers.ts
Normal file
52
ui/src/lib/helpers.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { nextTick, type UnwrapRef } from "vue";
|
||||
import { APIError } from "./api_error";
|
||||
|
||||
export function objForEach<T>(
|
||||
obj: T,
|
||||
f: (k: keyof T, v: T[keyof T]) => void,
|
||||
): void {
|
||||
for (const k in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, k)) {
|
||||
f(k, obj[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type VueLike<T> = T | UnwrapRef<T>;
|
||||
|
||||
export function unwrap_vuelike<T>(value: VueLike<T>): T {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export type Loading<T> = T | "loading" | "error";
|
||||
|
||||
export function unwrap_loading<T>(o: Loading<T>): T {
|
||||
if (o === "loading" || o === "error") throw null;
|
||||
|
||||
return o;
|
||||
}
|
||||
|
||||
export function wait_for(condition: () => boolean, action: () => void): void {
|
||||
const enqueue_action = () => {
|
||||
if (!condition()) {
|
||||
nextTick(enqueue_action);
|
||||
return;
|
||||
}
|
||||
|
||||
action();
|
||||
};
|
||||
enqueue_action();
|
||||
}
|
||||
|
||||
export function handle_error(error: unknown): void {
|
||||
if (error instanceof APIError) {
|
||||
error.alert();
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function name_door(day: number): string {
|
||||
return `Türchen ${day}`;
|
||||
}
|
||||
70
ui/src/lib/model.ts
Normal file
70
ui/src/lib/model.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
export interface AdminConfigModel {
|
||||
solution: {
|
||||
value: string;
|
||||
whitespace: string;
|
||||
special_chars: string;
|
||||
case: string;
|
||||
clean: string;
|
||||
};
|
||||
puzzle: {
|
||||
first: string;
|
||||
next: string | null;
|
||||
last: string;
|
||||
end: string;
|
||||
seed: string;
|
||||
extra_days: number[];
|
||||
skip_empty: boolean;
|
||||
};
|
||||
calendar: {
|
||||
config_file: string;
|
||||
background: string;
|
||||
favicon: string;
|
||||
};
|
||||
image: {
|
||||
size: number;
|
||||
border: number;
|
||||
};
|
||||
fonts: { file: string; size: number }[];
|
||||
redis: {
|
||||
host: string;
|
||||
port: number;
|
||||
db: number;
|
||||
protocol: number;
|
||||
};
|
||||
webdav: {
|
||||
url: string;
|
||||
cache_ttl: number;
|
||||
config_file: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SiteConfigModel {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
content: string;
|
||||
footer: string;
|
||||
}
|
||||
|
||||
export interface NumStrDict {
|
||||
[key: number]: string;
|
||||
}
|
||||
|
||||
export interface DoorSaved {
|
||||
day: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
export interface ImageData {
|
||||
width: number;
|
||||
height: number;
|
||||
aspect_ratio: number;
|
||||
data_url: string;
|
||||
}
|
||||
|
||||
export interface Credentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { DoorSaved } from "./api";
|
||||
import { type VueLike, unwrap_vuelike } from "../helpers";
|
||||
import type { DoorSaved } from "../model";
|
||||
import { Rectangle } from "./rectangle";
|
||||
import { Vector2D } from "./vector2d";
|
||||
|
||||
|
|
@ -8,26 +9,27 @@ export class Door {
|
|||
private _day = Door.MIN_DAY;
|
||||
public position: Rectangle;
|
||||
|
||||
constructor(position: Rectangle);
|
||||
constructor(position: Rectangle, day: number);
|
||||
constructor(position: Rectangle, day = Door.MIN_DAY) {
|
||||
constructor(position: VueLike<Rectangle>);
|
||||
constructor(position: VueLike<Rectangle>, day: number);
|
||||
constructor(position: VueLike<Rectangle>, day = Door.MIN_DAY) {
|
||||
this.day = day;
|
||||
this.position = position;
|
||||
this.position = unwrap_vuelike(position);
|
||||
}
|
||||
|
||||
public get day(): number {
|
||||
return this._day;
|
||||
}
|
||||
|
||||
public set day(day: unknown) {
|
||||
public set day(value: number | string) {
|
||||
// integer coercion
|
||||
const result = Number(day);
|
||||
let day = Number(value);
|
||||
|
||||
if (isNaN(result)) {
|
||||
this._day = Door.MIN_DAY;
|
||||
} else {
|
||||
this._day = Math.max(Math.floor(result), Door.MIN_DAY);
|
||||
}
|
||||
day =
|
||||
!Number.isNaN(day) && Number.isFinite(day)
|
||||
? Math.trunc(day)
|
||||
: Door.MIN_DAY;
|
||||
|
||||
this._day = Math.max(day, Door.MIN_DAY);
|
||||
}
|
||||
|
||||
public static load(serialized: DoorSaved): Door {
|
||||
|
|
@ -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 {
|
||||
131
ui/src/lib/store.ts
Normal file
131
ui/src/lib/store.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { acceptHMRUpdate, defineStore } from "pinia";
|
||||
import { API } from "./api";
|
||||
import type { Loading } from "./helpers";
|
||||
import type { Credentials, DoorSaved, ImageData, SiteConfigModel } from "./model";
|
||||
import { Door } from "./rects/door";
|
||||
|
||||
declare global {
|
||||
interface Navigator {
|
||||
readonly msMaxTouchPoints: number;
|
||||
}
|
||||
}
|
||||
|
||||
type State = {
|
||||
on_initialized: (() => void)[] | null;
|
||||
is_touch_device: boolean;
|
||||
is_admin: boolean;
|
||||
site_config: SiteConfigModel;
|
||||
background_image: Loading<ImageData>;
|
||||
user_doors: Door[];
|
||||
next_door_target: number | null;
|
||||
};
|
||||
|
||||
export const advent22Store = defineStore("advent22", {
|
||||
state: (): State => ({
|
||||
on_initialized: [],
|
||||
is_touch_device:
|
||||
window.matchMedia("(any-hover: none)").matches ||
|
||||
"ontouchstart" in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0,
|
||||
is_admin: false,
|
||||
site_config: {
|
||||
title: document.title,
|
||||
subtitle: "",
|
||||
content: "",
|
||||
footer: "",
|
||||
},
|
||||
background_image: "loading",
|
||||
user_doors: [],
|
||||
next_door_target: null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async init(): Promise<void> {
|
||||
await this.update();
|
||||
|
||||
if (this.on_initialized !== null) {
|
||||
for (const callback of this.on_initialized) callback();
|
||||
}
|
||||
this.on_initialized = null;
|
||||
},
|
||||
|
||||
async update(): Promise<void> {
|
||||
try {
|
||||
const favicon = await API.request<ImageData>("user/favicon");
|
||||
|
||||
const link: HTMLLinkElement =
|
||||
document.querySelector("link[rel*='icon']") ??
|
||||
document.createElement("link");
|
||||
link.rel = "shortcut icon";
|
||||
link.type = "image/x-icon";
|
||||
link.href = favicon.data_url;
|
||||
|
||||
if (link.parentElement === null)
|
||||
document.getElementsByTagName("head")[0]!.appendChild(link);
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const [is_admin, site_config, background_image, user_doors, next_door] =
|
||||
await Promise.all([
|
||||
this.update_is_admin(),
|
||||
API.request<SiteConfigModel>("user/site_config"),
|
||||
API.request<ImageData>("user/background_image"),
|
||||
API.request<DoorSaved[]>("user/doors"),
|
||||
API.request<number | null>("user/next_door"),
|
||||
]);
|
||||
void is_admin; // discard value
|
||||
|
||||
document.title = site_config.title;
|
||||
|
||||
if (site_config.subtitle !== "")
|
||||
document.title += " – " + site_config.subtitle;
|
||||
|
||||
this.site_config = site_config;
|
||||
this.background_image = background_image;
|
||||
|
||||
this.user_doors.length = 0;
|
||||
for (const door_saved of user_doors) {
|
||||
this.user_doors.push(Door.load(door_saved));
|
||||
}
|
||||
|
||||
if (next_door !== null) this.next_door_target = Date.now() + next_door;
|
||||
} catch {
|
||||
this.background_image = "error";
|
||||
}
|
||||
},
|
||||
|
||||
when_initialized(callback: () => void): void {
|
||||
if (this.on_initialized === null) {
|
||||
callback();
|
||||
} else {
|
||||
this.on_initialized.push(callback);
|
||||
}
|
||||
},
|
||||
|
||||
async update_is_admin(): Promise<boolean> {
|
||||
this.is_admin = await API.request<boolean>("admin/is_admin");
|
||||
return this.is_admin;
|
||||
},
|
||||
|
||||
async login(creds: Credentials): Promise<boolean> {
|
||||
API.creds = creds;
|
||||
return await this.update_is_admin();
|
||||
},
|
||||
|
||||
logout() {
|
||||
API.creds = null;
|
||||
this.is_admin = false;
|
||||
},
|
||||
|
||||
toggle_touch_device(): void {
|
||||
this.is_touch_device = !this.is_touch_device;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (import.meta.webpackHot) {
|
||||
import.meta.webpackHot.accept(
|
||||
acceptHMRUpdate(advent22Store, import.meta.webpackHot),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,64 +1,23 @@
|
|||
@charset "utf-8";
|
||||
@use "sass:map";
|
||||
|
||||
//===========
|
||||
// variables
|
||||
//===========
|
||||
//==============
|
||||
// bulma
|
||||
//==============
|
||||
|
||||
// custom color scheme
|
||||
@import "@/bulma-scheme";
|
||||
|
||||
// Sass variables (bulma)
|
||||
@import "~bulma/sass/utilities/initial-variables.sass";
|
||||
@import "~bulma/sass/utilities/derived-variables.sass";
|
||||
|
||||
// Sass variables (bulma-prefers-dark)
|
||||
@import "~bulma-prefers-dark/sass/utilities/initial-variables.sass";
|
||||
@import "~bulma-prefers-dark/sass/utilities/derived-variables.sass";
|
||||
|
||||
//=================
|
||||
// variable tweaks
|
||||
//=================
|
||||
|
||||
$modal-card-body-background-color-dark: $body-background-dark;
|
||||
$card-background-color-dark: $background-dark;
|
||||
@use "bulma-scheme" as scheme;
|
||||
@use "bulma/sass" with (
|
||||
$primary: map.get(scheme.$colors, "primary"),
|
||||
$link: map.get(scheme.$colors, "link"),
|
||||
$info: map.get(scheme.$colors, "info"),
|
||||
$success: map.get(scheme.$colors, "success"),
|
||||
$warning: map.get(scheme.$colors, "warning"),
|
||||
$danger: map.get(scheme.$colors, "danger")
|
||||
);
|
||||
|
||||
//==============
|
||||
// main imports
|
||||
//==============
|
||||
|
||||
@import "~animate.css/animate";
|
||||
@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;
|
||||
// }
|
||||
}
|
||||
@forward "animate.css/animate";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Advent22Plugin } from "@/plugins/advent22";
|
||||
import { FontAwesomePlugin } from "@/plugins/fontawesome";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import * as bulmaToast from "bulma-toast";
|
||||
import FontAwesomeIcon from "@/lib/fontawesome";
|
||||
import { advent22Store } from "@/lib/store";
|
||||
import { setDefaults as toast_set_defaults } from "bulma-toast";
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
|
@ -10,15 +9,14 @@ import "@/main.scss";
|
|||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(Advent22Plugin);
|
||||
app.use(FontAwesomePlugin);
|
||||
|
||||
app.use(createPinia());
|
||||
app.component("FontAwesomeIcon", FontAwesomeIcon);
|
||||
|
||||
advent22Store().init();
|
||||
|
||||
app.mount("#app");
|
||||
|
||||
bulmaToast.setDefaults({
|
||||
toast_set_defaults({
|
||||
duration: 10e3,
|
||||
pauseOnHover: true,
|
||||
dismissible: true,
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
import axios, { AxiosInstance, ResponseType } from "axios";
|
||||
import { App, Plugin } from "vue";
|
||||
import { advent22Store } from "./store";
|
||||
|
||||
export class Advent22 {
|
||||
private axios: AxiosInstance;
|
||||
|
||||
public constructor() {
|
||||
this.axios = axios.create({
|
||||
timeout: 10e3,
|
||||
});
|
||||
}
|
||||
|
||||
private get api_baseurl(): string {
|
||||
// in production mode, return "//host/api"
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return `//${window.location.host}/api`;
|
||||
} else if (process.env.NODE_ENV !== "development") {
|
||||
// not in prouction or development mode
|
||||
console.warn("Unexpected NODE_ENV value");
|
||||
}
|
||||
|
||||
// in development mode, return "//hostname:8000/api"
|
||||
return `//${window.location.hostname}:8000/api`;
|
||||
}
|
||||
|
||||
public name_door(day: number): string {
|
||||
return `Türchen ${day}`;
|
||||
}
|
||||
|
||||
public api_url(): string;
|
||||
public api_url(endpoint: string): string;
|
||||
public api_url(endpoint?: string): string {
|
||||
if (endpoint === undefined) {
|
||||
return this.api_baseurl;
|
||||
}
|
||||
|
||||
while (endpoint.startsWith("/")) {
|
||||
endpoint = endpoint.substring(1);
|
||||
}
|
||||
|
||||
return `${this.api_baseurl}/${endpoint}`;
|
||||
}
|
||||
|
||||
private _api_get<T>(endpoint: string): Promise<T>;
|
||||
private _api_get<T>(endpoint: string, responseType: ResponseType): Promise<T>;
|
||||
private _api_get<T>(
|
||||
endpoint: string,
|
||||
responseType: ResponseType = "json",
|
||||
): Promise<T> {
|
||||
const req_config = {
|
||||
auth: advent22Store().axios_creds,
|
||||
responseType: responseType,
|
||||
};
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.axios
|
||||
.get<T>(this.api_url(endpoint), req_config)
|
||||
.then((response) => resolve(response.data))
|
||||
.catch((reason) => {
|
||||
console.error(`Failed to query ${endpoint}: ${reason}`);
|
||||
reject([reason, endpoint]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public api_get<T>(endpoint: string): Promise<T> {
|
||||
return this._api_get<T>(endpoint);
|
||||
}
|
||||
|
||||
public api_get_blob(endpoint: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
this._api_get<Blob>(endpoint, "blob")
|
||||
.then((data: Blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(data);
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(["failed data url", endpoint]);
|
||||
}
|
||||
};
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
public api_put(endpoint: string, data: unknown): Promise<void> {
|
||||
const req_config = {
|
||||
auth: advent22Store().axios_creds,
|
||||
};
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.axios
|
||||
.put(this.api_url(endpoint), data, req_config)
|
||||
.then(() => resolve())
|
||||
.catch((reason) => {
|
||||
console.error(`Failed to query ${endpoint}: ${reason}`);
|
||||
reject([reason, endpoint]);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const Advent22Plugin: Plugin = {
|
||||
install(app: App) {
|
||||
app.config.globalProperties.$advent22 = new Advent22();
|
||||
},
|
||||
};
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
import { Credentials, DoorSaved, SiteConfigModel } from "@/lib/api";
|
||||
import { Door } from "@/lib/door";
|
||||
import { Advent22 } from "@/plugins/advent22";
|
||||
import { RemovableRef, useLocalStorage } from "@vueuse/core";
|
||||
import { AxiosBasicCredentials, AxiosError } from "axios";
|
||||
import { toast } from "bulma-toast";
|
||||
import { acceptHMRUpdate, defineStore } from "pinia";
|
||||
|
||||
declare global {
|
||||
interface Navigator {
|
||||
readonly msMaxTouchPoints: number;
|
||||
}
|
||||
}
|
||||
|
||||
type State = {
|
||||
advent22: Advent22;
|
||||
api_creds: RemovableRef<Credentials>;
|
||||
is_initialized: boolean;
|
||||
on_initialized: (() => void)[];
|
||||
is_touch_device: boolean;
|
||||
is_admin: boolean;
|
||||
site_config: SiteConfigModel;
|
||||
calendar_background_image: string | undefined;
|
||||
calendar_aspect_ratio: number;
|
||||
user_doors: Door[];
|
||||
next_door_target: number | null;
|
||||
};
|
||||
|
||||
export const advent22Store = defineStore({
|
||||
id: "advent22",
|
||||
|
||||
state: (): State => ({
|
||||
advent22: new Advent22(),
|
||||
api_creds: useLocalStorage("advent22/auth", ["", ""]),
|
||||
is_initialized: false,
|
||||
on_initialized: [],
|
||||
is_touch_device:
|
||||
window.matchMedia("(any-hover: none)").matches ||
|
||||
"ontouchstart" in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0,
|
||||
is_admin: false,
|
||||
site_config: {
|
||||
title: document.title,
|
||||
subtitle: "",
|
||||
content: "",
|
||||
footer: "",
|
||||
},
|
||||
calendar_background_image: undefined,
|
||||
calendar_aspect_ratio: 1,
|
||||
user_doors: [],
|
||||
next_door_target: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
axios_creds: (state): AxiosBasicCredentials => {
|
||||
const [username, password] = state.api_creds;
|
||||
return { username: username, password: password };
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
init(): void {
|
||||
this.update()
|
||||
.then(() => {
|
||||
this.is_initialized = true;
|
||||
for (const callback of this.on_initialized) callback();
|
||||
})
|
||||
.catch(this.alert_user_error);
|
||||
},
|
||||
|
||||
format_user_error([reason, endpoint]: [unknown, string]): string {
|
||||
let msg =
|
||||
"Unbekannter Fehler, bitte wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
|
||||
let code = "U";
|
||||
const result = () => `${msg} (Fehlercode: ${code}/${endpoint})`;
|
||||
|
||||
if (!(reason instanceof AxiosError)) return result();
|
||||
|
||||
switch (reason.code) {
|
||||
case "ECONNABORTED":
|
||||
// API unerreichbar
|
||||
msg =
|
||||
"API antwortet nicht, bitte später wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
|
||||
code = "D";
|
||||
break;
|
||||
|
||||
case "ERR_NETWORK":
|
||||
// Netzwerk nicht verbunden
|
||||
msg = "Sieht aus, als sei deine Netzwerkverbindung gestört.";
|
||||
code = "N";
|
||||
break;
|
||||
|
||||
default:
|
||||
if (reason.response === undefined) return result();
|
||||
|
||||
switch (reason.response.status) {
|
||||
case 401:
|
||||
// UNAUTHORIZED
|
||||
msg = "Netter Versuch :)";
|
||||
code = "A";
|
||||
break;
|
||||
|
||||
case 422:
|
||||
// UNPROCESSABLE ENTITY
|
||||
msg = "Funktion ist kaputt, bitte Admin benachrichtigen!";
|
||||
code = "I";
|
||||
break;
|
||||
|
||||
default:
|
||||
// HTTP
|
||||
code = `H${reason.response.status}`;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return result();
|
||||
},
|
||||
|
||||
alert_user_error(param: [unknown, string]): void {
|
||||
toast({
|
||||
message: this.format_user_error(param),
|
||||
type: "is-danger",
|
||||
});
|
||||
},
|
||||
|
||||
update(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.advent22
|
||||
.api_get_blob("user/favicon")
|
||||
.then((favicon_src) => {
|
||||
const link: HTMLLinkElement =
|
||||
document.querySelector("link[rel*='icon']") ||
|
||||
document.createElement("link");
|
||||
link.rel = "shortcut icon";
|
||||
link.type = "image/x-icon";
|
||||
link.href = favicon_src;
|
||||
|
||||
if (link.parentElement === null)
|
||||
document.getElementsByTagName("head")[0].appendChild(link);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
Promise.all([
|
||||
this.update_is_admin(),
|
||||
this.advent22.api_get<SiteConfigModel>("user/site_config"),
|
||||
this.advent22.api_get_blob("user/background_image"),
|
||||
this.advent22.api_get<DoorSaved[]>("user/doors"),
|
||||
this.advent22.api_get<number | null>("user/next_door"),
|
||||
])
|
||||
.then(
|
||||
([
|
||||
is_admin,
|
||||
site_config,
|
||||
background_image,
|
||||
user_doors,
|
||||
next_door,
|
||||
]) => {
|
||||
is_admin; // discard value
|
||||
|
||||
document.title = site_config.title;
|
||||
|
||||
if (site_config.subtitle !== "")
|
||||
document.title += " – " + site_config.subtitle;
|
||||
|
||||
this.site_config = site_config;
|
||||
|
||||
this.calendar_background_image = background_image;
|
||||
|
||||
this.user_doors.length = 0;
|
||||
for (const door_saved of user_doors) {
|
||||
this.user_doors.push(Door.load(door_saved));
|
||||
}
|
||||
|
||||
if (next_door !== null)
|
||||
this.next_door_target = Date.now() + next_door;
|
||||
|
||||
resolve();
|
||||
},
|
||||
)
|
||||
.catch(reject);
|
||||
});
|
||||
},
|
||||
|
||||
when_initialized(callback: () => void): void {
|
||||
if (this.is_initialized) {
|
||||
callback();
|
||||
} else {
|
||||
this.on_initialized.push(callback);
|
||||
}
|
||||
},
|
||||
|
||||
update_is_admin(): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.advent22
|
||||
.api_get<boolean>("admin/is_admin")
|
||||
.then((is_admin) => {
|
||||
this.is_admin = is_admin;
|
||||
resolve(is_admin);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
},
|
||||
|
||||
login(creds: Credentials): Promise<boolean> {
|
||||
this.api_creds = creds;
|
||||
return this.update_is_admin();
|
||||
},
|
||||
|
||||
logout(): Promise<boolean> {
|
||||
return this.login(["", ""]);
|
||||
},
|
||||
|
||||
toggle_touch_device(): void {
|
||||
this.is_touch_device = !this.is_touch_device;
|
||||
},
|
||||
|
||||
set_calendar_aspect_ratio(rect: DOMRectReadOnly): void {
|
||||
const result = rect.width / rect.height;
|
||||
|
||||
// filter suspicious results
|
||||
if (result !== 0 && isFinite(result) && !isNaN(result))
|
||||
this.calendar_aspect_ratio = result;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (import.meta.webpackHot) {
|
||||
import.meta.webpackHot.accept(
|
||||
acceptHMRUpdate(advent22Store, import.meta.webpackHot),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { expect } from "chai";
|
||||
|
||||
import { Rectangle } from "@/lib/rectangle";
|
||||
import { Vector2D } from "@/lib/vector2d";
|
||||
import { Rectangle } from "@/lib/rects/rectangle";
|
||||
import { Vector2D } from "@/lib/rects/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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { expect } from "chai";
|
||||
|
||||
import { Vector2D } from "@/lib/vector2d";
|
||||
import { Vector2D } from "@/lib/rects/vector2d";
|
||||
|
||||
describe("Vector2D Tests", () => {
|
||||
const v = new Vector2D(1, 2);
|
||||
|
|
|
|||
|
|
@ -1,43 +1,33 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useDefineForClassFields": true,
|
||||
"sourceMap": true,
|
||||
"lib": [
|
||||
"es2020",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es2022.object",
|
||||
"es2023.array",
|
||||
],
|
||||
// "moduleResolution": "node",
|
||||
// "sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"webpack-env",
|
||||
"mocha",
|
||||
"chai"
|
||||
"chai",
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
"src/*",
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"src/**/*.ts",
|
||||
// "src/**/*.tsx",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx"
|
||||
// "tests/**/*.tsx",
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { defineConfig } = require("@vue/cli-service");
|
||||
const webpack = require("webpack");
|
||||
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true,
|
||||
devServer: {
|
||||
host: "localhost",
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
pages: {
|
||||
index: {
|
||||
|
|
|
|||
4876
ui/yarn.lock
4876
ui/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue