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