Compare commits
513 commits
Author | SHA1 | Date | |
---|---|---|---|
04097ea1a3 | |||
c6e61d5d76 | |||
98ab638762 | |||
06ece81349 | |||
82a2963ad7 | |||
2c1a190abb | |||
e2a14821ba | |||
41889d9160 | |||
d396c2a8c3 | |||
f9f1de8987 | |||
090da8c679 | |||
082f50c66b | |||
d66019f53c | |||
7bc0ad21ba | |||
053aa5d2d2 | |||
ae9ca16aaa | |||
878bf34d52 | |||
10f41c34ae | |||
742f12aea5 | |||
859f9586d3 | |||
0a095f3a0a | |||
56db55681a | |||
b734dee575 | |||
77dd575e72 | |||
adbe740edf | |||
6d4ec2cbe7 | |||
08fc47a2e3 | |||
b4ab4809c6 | |||
cdf977f0db | |||
a86e47113c | |||
489d64414e | |||
4e66d304e5 | |||
785c74fd7b | |||
8c93a974e6 | |||
a63344dfe1 | |||
835bc6d2d0 | |||
4cb17c1b6e | |||
f6f7381745 | |||
9e9481217d | |||
15309e6710 | |||
223cc584a7 | |||
9e303f898a | |||
e5364ff94d | |||
3b22f09a13 | |||
3da3f7f639 | |||
f9111e7a45 | |||
c2892f6358 | |||
194a7bb0db | |||
2cf6936139 | |||
02ba2c67a5 | |||
eef38502c6 | |||
8cd4e922ff | |||
2282de2528 | |||
8b581a7b50 | |||
f9583571ed | |||
53bfdfd7b5 | |||
5213e0b8b1 | |||
e613ead635 | |||
9413b96c1a | |||
dcbcdc425a | |||
0eeaaef4bf | |||
11cb3fef38 | |||
9b34b9de8c | |||
bd0c4e4abd | |||
e2e4847eea | |||
4654032fa0 | |||
779ccd8e65 | |||
d61c27deca | |||
644b1eb3e3 | |||
6efa2aef9b | |||
b6e24dae9d | |||
ca8df3ba10 | |||
4fbdb94caa | |||
6d015420c0 | |||
0bc31529bc | |||
600ee99520 | |||
45ad00eaa6 | |||
adde270f9f | |||
dae862fc55 | |||
8e4ea78873 | |||
a0b576e14d | |||
19411351dd | |||
066b6686d8 | |||
39d375ced0 | |||
e4f9ded5c8 | |||
59cf7202f4 | |||
5683438a19 | |||
d0bdc62433 | |||
b8c30d130a | |||
8a254d2958 | |||
7951363be8 | |||
a1729d13f7 | |||
2a9635c8c1 | |||
367fef145d | |||
63b9f4e1d9 | |||
|
aaa12683c8 | ||
|
84c5467edb | ||
|
337d8d34ff | ||
|
558a7e8a02 | ||
cc54e1dddf | |||
b74646994e | |||
b422913d7c | |||
6ad52f8996 | |||
e1b228603e | |||
a781240af0 | |||
2e50dd447d | |||
b206b472bc | |||
bf6d72147e | |||
3d62486783 | |||
33733adc01 | |||
beaaa2b11d | |||
d1cde05be7 | |||
5e5b2b164e | |||
f91b3d17d7 | |||
fe90960e6a | |||
87856b06db | |||
6f09010d0d | |||
ce29116e88 | |||
0567cd9a4f | |||
7a3042a2b5 | |||
a72ba92f5e | |||
d8e011accc | |||
70d3489a29 | |||
b3a4a20f16 | |||
3a3dfcd94d | |||
1d760f91db | |||
6cbb15dc69 | |||
62f570aaca | |||
a15dd4fdfa | |||
cfea9b051f | |||
6e8b6549ea | |||
de5fff311c | |||
5fcc4f0684 | |||
5e0f797a2f | |||
c35fe495dc | |||
be6c03c84a | |||
558f8d22ff | |||
ff04499a02 | |||
b99a6ccc68 | |||
7fc0d82354 | |||
d0c43fb4c8 | |||
7bc94804e3 | |||
24e9c93eef | |||
e83f56a932 | |||
9063ebe93a | |||
bc4054ef73 | |||
6ba3947cb8 | |||
8b003419a5 | |||
8093b78af1 | |||
855897eda7 | |||
0c28febf1f | |||
d5ae079949 | |||
cc94aace76 | |||
0fcae295ec | |||
eef06ed131 | |||
82ab9ccddc | |||
75dcea25fb | |||
d4c0d1ef5e | |||
8626b1460a | |||
b644252b9c | |||
3bf46847e9 | |||
b4a4a3990b | |||
411d1492f4 | |||
126feaacdd | |||
1b71aab5b6 | |||
|
2d864dd57f | ||
|
776298a357 | ||
dbf6d72d1f | |||
77cb0c1da2 | |||
f7dc74d508 | |||
c55bef46dd | |||
bf2fa124e7 | |||
6611b228c1 | |||
444c850361 | |||
1bfa17a629 | |||
2394bd19a5 | |||
513b6ecf10 | |||
6bd8f66527 | |||
41368c7191 | |||
c93ec06925 | |||
a4a0893e7d | |||
97d4d1e136 | |||
e9ef7f67e3 | |||
47eb42f0f5 | |||
da09db3bda | |||
597f01b545 | |||
1b42cba9fa | |||
6b28c08fb9 | |||
0498a722b1 | |||
610a7838c0 | |||
106797a70a | |||
73f80ae36d | |||
f7e98ed6f7 | |||
4143b4e415 | |||
c43cbd507f | |||
073a9d5e5a | |||
5b4e057279 | |||
421f7d185c | |||
c4b1c180e0 | |||
9ebf50218c | |||
47375105ac | |||
dd40fa9392 | |||
5f2c031793 | |||
6012cec657 | |||
bd7bc38954 | |||
2d0209ae61 | |||
60a451a426 | |||
d7818ac93a | |||
764c6b16fb | |||
4686ab9c56 | |||
05f5bed4d0 | |||
db5b056866 | |||
d4f8469a7a | |||
0550621835 | |||
8290925659 | |||
5694438c96 | |||
3ccd3da28a | |||
51beac231c | |||
a24773ec64 | |||
d3aded71bd | |||
fd821d6ff2 | |||
629c6d8f9d | |||
d1e2648f5f | |||
4e1a9fa10d | |||
334540187e | |||
c7fd54f60d | |||
ddb893ea6c | |||
dbd0e4962c | |||
2f4842f539 | |||
0bf2ce0b9e | |||
3892276313 | |||
3aa59c73df | |||
c718ab7d42 | |||
f5825ae19b | |||
a49ed8dd79 | |||
20375f2c9d | |||
3316bf2822 | |||
63d88c3a09 | |||
a84323afc8 | |||
a11c16d17b | |||
95dfe2a9df | |||
b73185e4fb | |||
2ae4fb8695 | |||
08972df4cc | |||
b30e8095f9 | |||
2da7b6914a | |||
26556ad309 | |||
74b9322ae2 | |||
d5d99caeb2 | |||
0337c659a2 | |||
91c2589045 | |||
02cb654022 | |||
4415c0f861 | |||
5c583bd478 | |||
b00fdee126 | |||
f4c8ab0b5a | |||
f9369ec204 | |||
6e97420f1b | |||
451fd6bb88 | |||
657a7507f7 | |||
|
050c8408d9 | ||
4684df16f9 | |||
6f73440160 | |||
0d07f67642 | |||
684443e6a6 | |||
a645dc84cc | |||
5b5d85e9d9 | |||
8389ceb14a | |||
c4181dc5f8 | |||
99e9f5980f | |||
d4bd41fc67 | |||
cfeb87401e | |||
943c9b6c32 | |||
ce344e5043 | |||
5c38c2fcd8 | |||
6e4578ec5e | |||
74c22fc8a6 | |||
c5680f37f6 | |||
f75f5689de | |||
63f05c1fb9 | |||
3ab2a56172 | |||
5ca3112619 | |||
3153f57613 | |||
e51744ddc9 | |||
f9f9989414 | |||
21279f747c | |||
11daf2bf8b | |||
973f7546b6 | |||
29f02d2545 | |||
af00dafb6c | |||
b1748ea0fb | |||
d51db8b836 | |||
e894aad746 | |||
5223efddb4 | |||
d27d952d38 | |||
6ac19ee866 | |||
14896960c2 | |||
74688f45b8 | |||
c8ce12c299 | |||
d1158b1e10 | |||
69e4590305 | |||
452040a0ae | |||
30c5788d4d | |||
5cf0846c68 | |||
a5751c973a | |||
a0268f52b9 | |||
2e0004bfcb | |||
d05e3b94be | |||
90c3643bf1 | |||
83098d3dc4 | |||
7821aebd68 | |||
a9754f77bc | |||
27db0ea908 | |||
b3448dbfa8 | |||
62a93d41c7 | |||
35257b227a | |||
79fc7dc89d | |||
1866833d3d | |||
a6e6d8eb19 | |||
dd156b936d | |||
0dd96f5949 | |||
9cd2e51eb8 | |||
f603a9947c | |||
3ce2480dcc | |||
40284c4061 | |||
4be3e38962 | |||
84be0cbc71 | |||
f202f78d51 | |||
560beb0a44 | |||
3a4d2c1598 | |||
9f3339240f | |||
d26a75ebcc | |||
23820bc9dc | |||
391f3e8fba | |||
17b6950491 | |||
05750d6de2 | |||
dd519ec3cc | |||
f7754ec901 | |||
e097e3011e | |||
d159485b2c | |||
0767e91d7d | |||
61f8b7bbe6 | |||
566da81a20 | |||
00df526099 | |||
eb1f421b0d | |||
0d79490d47 | |||
5d8ca2fd2e | |||
f0bac2d168 | |||
5a07ca5dfd | |||
48f58a7953 | |||
73bfc16bfd | |||
d654fb8f6b | |||
fb20275775 | |||
c47ecdab18 | |||
61ad59e90a | |||
b0766933b0 | |||
aa282c122c | |||
4326bfb8b8 | |||
dc970deb6c | |||
b9594ade83 | |||
2551b0480b | |||
bb89d49f0d | |||
5903e43e01 | |||
d13d5b1525 | |||
0a18bdd2a0 | |||
67c371b483 | |||
1ab02e6905 | |||
ef5281e57b | |||
d0756fda92 | |||
1695e8651c | |||
6628efe957 | |||
6d7646daf7 | |||
0372ad6c4d | |||
8efe6ee17c | |||
c74c63be54 | |||
78f2b981e3 | |||
be78671ffe | |||
3af743a6e6 | |||
f87b25f730 | |||
9ee55fc5af | |||
f3a73f8ad0 | |||
ba066c5779 | |||
01e88c6059 | |||
75306742c5 | |||
31ee11fbf2 | |||
cba03dfeda | |||
f12bcf01b7 | |||
fbf7f466cb | |||
2882f35b06 | |||
9431673eee | |||
a977b9b11c | |||
a6c5a5469a | |||
42ce94f313 | |||
bf5f4ffbb3 | |||
a55d1a25ba | |||
049763a411 | |||
6ee2a80a23 | |||
f1b3cbeeab | |||
8048b68725 | |||
709e0c81e4 | |||
9d9e3faa02 | |||
5752d2b814 | |||
dde8f172dc | |||
1ca9ca6577 | |||
33b8a66b23 | |||
47d7815067 | |||
641492339a | |||
a715e761a9 | |||
1e5fae0caa | |||
c0bc9aee5d | |||
63f5b3fe19 | |||
e8dd32c71f | |||
22d58f94b6 | |||
2db63db8db | |||
3bf8872a33 | |||
9647132569 | |||
93f31cd2b8 | |||
d387aceedd | |||
95c4c2e5ff | |||
26564a5cd3 | |||
dcdb28b360 | |||
714e31b1d7 | |||
f7f5646699 | |||
2f776566f3 | |||
8689ba8473 | |||
6ffc5f076c | |||
740eb574c6 | |||
b81446e99d | |||
c1da30cc3e | |||
7160cfeff7 | |||
7650fb16a5 | |||
b4492dcb73 | |||
cc1ab7be90 | |||
10751cb798 | |||
eae3eed29b | |||
b7f7b05e73 | |||
4fe8bf1847 | |||
96de19fbbd | |||
9fa1498af3 | |||
d16abe2658 | |||
f3f8522242 | |||
e1bd10980c | |||
f0db5e57ec | |||
|
2052efedd9 | ||
|
cb7c785ef4 | ||
|
e53619ebcf | ||
|
82ebd211f6 | ||
|
fcf9cd458e | ||
|
0ce4e123d9 | ||
|
f81933946c | ||
|
b823aeaa69 | ||
|
8269d8c976 | ||
|
a7666c741d | ||
|
2b05e468e6 | ||
e6f1f77ec3 | |||
a3583396ec | |||
09672cda69 | |||
de4881ced3 | |||
a7d9b4a3b5 | |||
9f81895600 | |||
e9232fdcac | |||
cae145ea6a | |||
0673d239d9 | |||
535239c1ed | |||
66dbf9c9d8 | |||
2802b04657 | |||
fb9cfc0390 | |||
742023d191 | |||
8a279a2a11 | |||
65922626bb | |||
78d9f7d194 | |||
9da577c613 | |||
ffd1133b24 | |||
231dfe6f9e | |||
689013e202 | |||
f707494058 | |||
c1ea31f874 | |||
f585b5f90c | |||
cf318212ba | |||
b6deb22cc8 | |||
382ed35a48 | |||
858062b9ee | |||
2c584cff8b | |||
4567ab12f8 | |||
cf812f23cd | |||
c0302fbe3c | |||
a01e8c8a8a | |||
12c46acf69 | |||
b93f95020d | |||
4ea2b37362 | |||
3bee806262 | |||
12ed54f925 | |||
f0b314fd7c | |||
71482983bf | |||
7fc8f8bbc5 | |||
752c449831 | |||
f7f3fec891 | |||
0c51199215 | |||
3c925726d3 | |||
19336c542c | |||
444c16b0b4 | |||
d13006ce95 | |||
0e792d4304 | |||
25019c8ccc | |||
b6e9ade1b2 | |||
2e6daa9da3 | |||
f49f12c981 | |||
25e7aa9f86 | |||
1431101fcd | |||
ac2ab19a76 | |||
c278bed0b7 | |||
69f41cc0d2 | |||
0f5fa3e1c7 |
84 changed files with 14136 additions and 0 deletions
27
.dockerignore
Normal file
27
.dockerignore
Normal file
|
@ -0,0 +1,27 @@
|
|||
# commonly found
|
||||
**/.git
|
||||
**/.idea
|
||||
**/.DS_Store
|
||||
**/.vscode
|
||||
**/.devcontainer
|
||||
|
||||
**/dist
|
||||
**/.gitignore
|
||||
**/Dockerfile
|
||||
**/.dockerignore
|
||||
|
||||
# found in python and JS dirs
|
||||
**/__pycache__
|
||||
**/node_modules
|
||||
**/.pytest_cache
|
||||
|
||||
# env files
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
|
||||
# log files
|
||||
**/npm-debug.log*
|
||||
**/yarn-debug.log*
|
||||
**/yarn-error.log*
|
||||
**/pnpm-debug.log*
|
47
Dockerfile
Normal file
47
Dockerfile
Normal file
|
@ -0,0 +1,47 @@
|
|||
############
|
||||
# build ui #
|
||||
############
|
||||
|
||||
ARG NODE_VERSION=18.16
|
||||
ARG PYTHON_VERSION=3.11-slim
|
||||
FROM node:${NODE_VERSION} AS build-ui
|
||||
|
||||
# env setup
|
||||
WORKDIR /usr/local/src/advent22_ui
|
||||
|
||||
# install advent22_ui dependencies
|
||||
COPY ui/package*.json ui/yarn*.lock ./
|
||||
RUN yarn install --production false
|
||||
|
||||
# copy and build advent22_ui
|
||||
COPY ui ./
|
||||
RUN yarn build --dest /tmp/advent22_ui/html
|
||||
|
||||
###########
|
||||
# web app #
|
||||
###########
|
||||
|
||||
ARG PYTHON_VERSION
|
||||
FROM tiangolo/uvicorn-gunicorn:python${PYTHON_VERSION} AS production
|
||||
|
||||
# env setup
|
||||
WORKDIR /usr/local/src/advent22_api
|
||||
ENV \
|
||||
PRODUCTION_MODE="true" \
|
||||
PORT="8000" \
|
||||
MODULE_NAME="advent22_api.app"
|
||||
EXPOSE 8000
|
||||
|
||||
# install advent22_api
|
||||
COPY api ./
|
||||
RUN set -ex; \
|
||||
# remove example app
|
||||
rm -rf /app; \
|
||||
\
|
||||
python -m pip --no-cache-dir install ./
|
||||
|
||||
# add prepared advent22_ui
|
||||
COPY --from=build-ui /tmp/advent22_ui /usr/local/share/advent22_ui
|
||||
|
||||
# run as unprivileged user
|
||||
USER nobody
|
23
Ideen.md
Normal file
23
Ideen.md
Normal file
|
@ -0,0 +1,23 @@
|
|||
# MUSS
|
||||
|
||||
|
||||
# KANN
|
||||
|
||||
- api/ui: Türchen mit Tag "0" einem zufälligen Tag zuweisen
|
||||
- api/?: Option "custom Zuordnung Buchstaben" (standard leer)
|
||||
- ui: `confirm` durch bulma Komponente(n) ersetzen
|
||||
- halbautomatischer Modus: Finde Bilder wie "a.jpg" und "Z.png" und weise diese den passenden Tagen zu
|
||||
|
||||
# Erledigt
|
||||
|
||||
- Türchen anzeigen im DoorMapEditor
|
||||
- Lösungsbuchstaben weniger als türchen erzeugt bug
|
||||
- Türchen sichtbar machen (besser für touch, standard nein)
|
||||
- Option "Nur Groß-/Kleinbuchstaben" (standard nur groß)
|
||||
- Option "Leerzeichen ignorieren" (standard ja)
|
||||
- Nach einigen Sekunden: Meldung "Türchen anzeigen?"
|
||||
- `alert` durch bulma Komponente(n) ersetzen
|
||||
- api: admin Login case sensitivity (username "admin" == "AdMiN")
|
||||
- api: `config.solution` - whitespace="IGNORE"->"REMOVE" umbenennen, +Sonderzeichen
|
||||
- api: Config-Option "Überspringe leere Türchen" (standard ja)
|
||||
- api: Config-Liste von Extra-Türchen (kein Buchstabe, nur manuelles Bild)
|
50
api/.devcontainer/devcontainer.json
Normal file
50
api/.devcontainer/devcontainer.json
Normal file
|
@ -0,0 +1,50 @@
|
|||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
"name": "Advent22 API",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/vscode/devcontainers/python:1-3.11-bookworm",
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
"features": {
|
||||
"ghcr.io/devcontainers-contrib/features/poetry:2": {},
|
||||
"ghcr.io/devcontainers-contrib/features/apt-get-packages:1": {
|
||||
"packages": "git-flow, git-lfs"
|
||||
},
|
||||
"ghcr.io/itsmechlark/features/redis-server:1": {}
|
||||
},
|
||||
"containerEnv": {
|
||||
"TZ": "Europe/Berlin"
|
||||
},
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"terminal.integrated.defaultProfile.linux": "zsh"
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"be5invis.toml",
|
||||
"mhutchie.git-graph",
|
||||
"ms-python.python",
|
||||
"ms-python.black-formatter",
|
||||
"ms-python.flake8",
|
||||
"ms-python.isort",
|
||||
"ms-python.vscode-pylance"
|
||||
]
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "sudo /usr/local/py-utils/bin/poetry self add poetry-plugin-up",
|
||||
// Use 'postStartCommand' to run commands after the container is started.
|
||||
"postStartCommand": "poetry install"
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "pip3 install --user -r requirements.txt",
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
4
api/.flake8
Normal file
4
api/.flake8
Normal file
|
@ -0,0 +1,4 @@
|
|||
[flake8]
|
||||
max-line-length = 80
|
||||
select = C,E,F,W,B,B950
|
||||
extend-ignore = E203, E501
|
1
.gitignore → api/.gitignore
vendored
1
.gitignore → api/.gitignore
vendored
|
@ -152,3 +152,4 @@ cython_debug/
|
|||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
api.conf
|
22
api/.vscode/launch.json
vendored
Normal file
22
api/.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
// Verwendet IntelliSense zum Ermitteln möglicher Attribute.
|
||||
// Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen.
|
||||
// Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Main Module",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"module": "advent22_api.main",
|
||||
"pythonArgs": [
|
||||
"-Xfrozen_modules=off",
|
||||
],
|
||||
"env": {
|
||||
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
|
||||
"WEBDAV__CACHE_TTL": "30",
|
||||
},
|
||||
"justMyCode": true,
|
||||
}
|
||||
]
|
||||
}
|
20
api/.vscode/settings.json
vendored
Normal file
20
api/.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"python.languageServer": "Pylance",
|
||||
"editor.formatOnSave": true,
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
"git.closeDiffOnOperation": true,
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.analysis.diagnosticMode": "workspace",
|
||||
"python.testing.pytestArgs": [
|
||||
"test"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"black-formatter.importStrategy": "fromEnvironment",
|
||||
"flake8.importStrategy": "fromEnvironment",
|
||||
}
|
46
api/advent22_api/app.py
Normal file
46
api/advent22_api/app.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .core.settings import SETTINGS
|
||||
from .routers import router
|
||||
|
||||
app = FastAPI(
|
||||
title="Advent22 API",
|
||||
description="This API enables the `Advent22` service.",
|
||||
contact={
|
||||
"name": "Jörn-Michael Miehe",
|
||||
"email": "jmm@yavook.de",
|
||||
},
|
||||
license_info={
|
||||
"name": "MIT License",
|
||||
"url": "https://opensource.org/licenses/mit-license.php",
|
||||
},
|
||||
openapi_url=SETTINGS.openapi_url,
|
||||
docs_url=SETTINGS.docs_url,
|
||||
redoc_url=SETTINGS.redoc_url,
|
||||
)
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
if SETTINGS.production_mode:
|
||||
# Mount frontend in production mode
|
||||
app.mount(
|
||||
path="/",
|
||||
app=StaticFiles(
|
||||
directory=SETTINGS.ui_directory,
|
||||
html=True,
|
||||
),
|
||||
name="frontend",
|
||||
)
|
||||
|
||||
else:
|
||||
# Allow CORS in debug mode
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_credentials=True,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["*"],
|
||||
)
|
0
api/advent22_api/core/__init__.py
Normal file
0
api/advent22_api/core/__init__.py
Normal file
138
api/advent22_api/core/advent_image.py
Normal file
138
api/advent22_api/core/advent_image.py
Normal file
|
@ -0,0 +1,138 @@
|
|||
import colorsys
|
||||
from dataclasses import dataclass
|
||||
from typing import Self, TypeAlias, cast
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .config import Config
|
||||
|
||||
_RGB: TypeAlias = tuple[int, int, int]
|
||||
_XY: TypeAlias = tuple[float, float]
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class AdventImage:
|
||||
img: Image.Image
|
||||
|
||||
@classmethod
|
||||
async def from_img(cls, img: Image.Image, cfg: Config) -> Self:
|
||||
"""
|
||||
Einen quadratischen Ausschnitt aus der Mitte des Bilds nehmen
|
||||
"""
|
||||
|
||||
# Farbmodell festlegen
|
||||
img = img.convert(mode="RGB")
|
||||
|
||||
# Größen bestimmen
|
||||
width, height = img.size
|
||||
square = min(width, height)
|
||||
|
||||
# zuschneiden
|
||||
img = img.crop(
|
||||
box=(
|
||||
int((width - square) / 2),
|
||||
int((height - square) / 2),
|
||||
int((width + square) / 2),
|
||||
int((height + square) / 2),
|
||||
)
|
||||
)
|
||||
|
||||
# skalieren
|
||||
return cls(
|
||||
img.resize(
|
||||
size=(cfg.image.size, cfg.image.size),
|
||||
resample=Image.LANCZOS,
|
||||
)
|
||||
)
|
||||
|
||||
async def get_text_box(
|
||||
self,
|
||||
xy: _XY,
|
||||
text: str | bytes,
|
||||
font: "ImageFont._Font",
|
||||
anchor: str | None = "mm",
|
||||
**text_kwargs,
|
||||
) -> "Image._Box | None":
|
||||
"""
|
||||
Koordinaten (links, oben, rechts, unten) des betroffenen
|
||||
Rechtecks bestimmen, wenn das Bild mit einem Text
|
||||
versehen wird
|
||||
"""
|
||||
|
||||
# Neues 1-Bit Bild, gleiche Größe
|
||||
mask = Image.new(mode="1", size=self.img.size, color=0)
|
||||
|
||||
# Text auf Maske auftragen
|
||||
ImageDraw.Draw(mask).text(
|
||||
xy=xy,
|
||||
text=text,
|
||||
font=font,
|
||||
anchor=anchor,
|
||||
fill=1,
|
||||
**text_kwargs,
|
||||
)
|
||||
|
||||
# betroffenen Pixelbereich bestimmen
|
||||
return mask.getbbox()
|
||||
|
||||
async def get_average_color(
|
||||
self,
|
||||
box: "Image._Box",
|
||||
) -> tuple[int, int, int]:
|
||||
"""
|
||||
Durchschnittsfarbe eines rechteckigen Ausschnitts in
|
||||
einem Bild berechnen
|
||||
"""
|
||||
|
||||
pixel_data = self.img.crop(box).getdata()
|
||||
mean_color: np.ndarray = np.mean(pixel_data, axis=0)
|
||||
|
||||
return cast(_RGB, tuple(mean_color.astype(int)))
|
||||
|
||||
async def hide_text(
|
||||
self,
|
||||
xy: _XY,
|
||||
text: str | bytes,
|
||||
font: "ImageFont._Font",
|
||||
anchor: str | None = "mm",
|
||||
**text_kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
Text `text` in Bild an Position `xy` verstecken.
|
||||
Weitere Parameter wie bei `ImageDraw.text()`.
|
||||
"""
|
||||
|
||||
# betroffenen Bildbereich bestimmen
|
||||
text_box = await self.get_text_box(
|
||||
xy=xy, text=text, font=font, anchor=anchor, **text_kwargs
|
||||
)
|
||||
|
||||
if text_box is not None:
|
||||
# Durchschnittsfarbe bestimmen
|
||||
text_color = await self.get_average_color(
|
||||
box=text_box,
|
||||
)
|
||||
|
||||
# etwas heller/dunkler machen
|
||||
tc_h, tc_s, tc_v = colorsys.rgb_to_hsv(*text_color)
|
||||
tc_v = int((tc_v - 127) * 0.97) + 127
|
||||
|
||||
if tc_v < 127:
|
||||
tc_v += 3
|
||||
|
||||
else:
|
||||
tc_v -= 3
|
||||
|
||||
text_color = colorsys.hsv_to_rgb(tc_h, tc_s, tc_v)
|
||||
text_color = tuple(int(val) for val in text_color)
|
||||
|
||||
# Buchstaben verstecken
|
||||
ImageDraw.Draw(self.img).text(
|
||||
xy=xy,
|
||||
text=text,
|
||||
font=font,
|
||||
fill=cast(_RGB, text_color),
|
||||
anchor=anchor,
|
||||
**text_kwargs,
|
||||
)
|
55
api/advent22_api/core/calendar_config.py
Normal file
55
api/advent22_api/core/calendar_config.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
import tomllib
|
||||
from typing import TypeAlias
|
||||
|
||||
import tomli_w
|
||||
from fastapi import Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .config import Config, get_config
|
||||
from .dav.webdav import WebDAV
|
||||
|
||||
|
||||
class DoorSaved(BaseModel):
|
||||
# Tag, an dem die Tür aufgeht
|
||||
day: int
|
||||
|
||||
# Koordinaten für zwei Eckpunkte
|
||||
x1: int
|
||||
y1: int
|
||||
x2: int
|
||||
y2: int
|
||||
|
||||
|
||||
DoorsSaved: TypeAlias = list[DoorSaved]
|
||||
|
||||
|
||||
class CalendarConfig(BaseModel):
|
||||
# Dateiname Hintergrundbild
|
||||
background: str = "adventskalender.jpg"
|
||||
|
||||
# Dateiname Favicon
|
||||
favicon: str = "favicon.png"
|
||||
|
||||
# Türen für die UI
|
||||
doors: DoorsSaved = []
|
||||
|
||||
async def change(self, cfg: Config) -> None:
|
||||
"""
|
||||
Kalender Konfiguration ändern
|
||||
"""
|
||||
|
||||
await WebDAV.write_str(
|
||||
path=f"files/{cfg.calendar}",
|
||||
content=tomli_w.dumps(self.model_dump()),
|
||||
)
|
||||
|
||||
|
||||
async def get_calendar_config(
|
||||
cfg: Config = Depends(get_config),
|
||||
) -> CalendarConfig:
|
||||
"""
|
||||
Kalender Konfiguration lesen
|
||||
"""
|
||||
|
||||
txt = await WebDAV.read_str(path=f"files/{cfg.calendar}")
|
||||
return CalendarConfig.model_validate(tomllib.loads(txt))
|
86
api/advent22_api/core/config.py
Normal file
86
api/advent22_api/core/config.py
Normal file
|
@ -0,0 +1,86 @@
|
|||
import tomllib
|
||||
|
||||
from markdown import markdown
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from .dav.webdav import WebDAV
|
||||
from .settings import SETTINGS
|
||||
from .transformed_string import TransformedString
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
name: str
|
||||
password: str
|
||||
|
||||
|
||||
class Site(BaseModel):
|
||||
model_config = ConfigDict(validate_default=True)
|
||||
|
||||
# Titel
|
||||
title: str
|
||||
|
||||
# Untertitel
|
||||
subtitle: str
|
||||
|
||||
# Inhalt der Seite
|
||||
content: str
|
||||
|
||||
# Fußzeile der Seite
|
||||
footer: str = "**Advent22** by [Lenaisten e.V.](//www.lenaisten.de)"
|
||||
|
||||
@field_validator("content", "footer", mode="after")
|
||||
def parse_md(cls, v) -> str:
|
||||
return markdown(v)
|
||||
|
||||
|
||||
class Puzzle(BaseModel):
|
||||
# Tag, an dem der Kalender startet
|
||||
begin_day: int = 1
|
||||
|
||||
# Monat, in dem der Kalender startet
|
||||
begin_month: int = 12
|
||||
|
||||
# Kalender so viele Tage nach der letzten Türöffnung schließen
|
||||
close_after: int = 90
|
||||
|
||||
# Tage, für die kein Buchstabe vorgesehen wird
|
||||
extra_days: set[int] = set()
|
||||
|
||||
# Türchen ohne Buchstabe überspringen
|
||||
skip_empty: bool = True
|
||||
|
||||
|
||||
class Image(BaseModel):
|
||||
# Quadrat, Seitenlänge in px
|
||||
size: int = 1000
|
||||
|
||||
# Rand in px, wo keine Buchstaben untergebracht werden
|
||||
border: int = 60
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
# Login-Daten für Admin-Modus
|
||||
admin: User
|
||||
|
||||
# Lösungswort
|
||||
solution: TransformedString
|
||||
|
||||
# Weitere Einstellungen
|
||||
site: Site
|
||||
puzzle: Puzzle
|
||||
image: Image
|
||||
|
||||
# Kalenderdefinition
|
||||
calendar: str = "default.toml"
|
||||
|
||||
# Serverseitiger zusätzlicher "random" seed
|
||||
random_seed: str = ""
|
||||
|
||||
|
||||
async def get_config() -> Config:
|
||||
"""
|
||||
Globale Konfiguration lesen
|
||||
"""
|
||||
|
||||
txt = await WebDAV.read_str(path=SETTINGS.webdav.config_filename)
|
||||
return Config.model_validate(tomllib.loads(txt))
|
0
api/advent22_api/core/dav/__init__.py
Normal file
0
api/advent22_api/core/dav/__init__.py
Normal file
61
api/advent22_api/core/dav/helpers.py
Normal file
61
api/advent22_api/core/dav/helpers.py
Normal file
|
@ -0,0 +1,61 @@
|
|||
from json import JSONDecodeError
|
||||
from typing import Callable, Hashable
|
||||
|
||||
import requests
|
||||
from cachetools.keys import hashkey
|
||||
from CacheToolsUtils import RedisCache as __RedisCache
|
||||
from redis.commands.core import ResponseT
|
||||
from redis.typing import EncodableT
|
||||
from webdav3.client import Client as __WebDAVclient
|
||||
|
||||
|
||||
def davkey(
|
||||
name: str,
|
||||
slice: slice = slice(1, None),
|
||||
) -> Callable[..., tuple[Hashable, ...]]:
|
||||
def func(*args, **kwargs) -> tuple[Hashable, ...]:
|
||||
"""Return a cache key for use with cached methods."""
|
||||
|
||||
key = hashkey(name, *args[slice], **kwargs)
|
||||
return hashkey(*(str(key_item) for key_item in key))
|
||||
|
||||
return func
|
||||
|
||||
|
||||
class WebDAVclient(__WebDAVclient):
|
||||
def execute_request(
|
||||
self,
|
||||
action,
|
||||
path,
|
||||
data=None,
|
||||
headers_ext=None,
|
||||
) -> requests.Response:
|
||||
res = super().execute_request(action, path, data, headers_ext)
|
||||
|
||||
# the "Content-Length" header can randomly be missing on txt files,
|
||||
# this should fix that (probably serverside bug)
|
||||
if action == "download" and "Content-Length" not in res.headers:
|
||||
res.headers["Content-Length"] = str(len(res.text))
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class RedisCache(__RedisCache):
|
||||
"""
|
||||
Redis handles <bytes>, so ...
|
||||
"""
|
||||
|
||||
def _serialize(self, s) -> EncodableT:
|
||||
if isinstance(s, bytes):
|
||||
return s
|
||||
|
||||
else:
|
||||
return super()._serialize(s)
|
||||
|
||||
def _deserialize(self, s: ResponseT):
|
||||
try:
|
||||
return super()._deserialize(s)
|
||||
|
||||
except (UnicodeDecodeError, JSONDecodeError):
|
||||
assert isinstance(s, bytes)
|
||||
return s
|
108
api/advent22_api/core/dav/webdav.py
Normal file
108
api/advent22_api/core/dav/webdav.py
Normal file
|
@ -0,0 +1,108 @@
|
|||
import logging
|
||||
import re
|
||||
from io import BytesIO
|
||||
|
||||
from asyncify import asyncify
|
||||
from cachetools import cachedmethod
|
||||
from redis import Redis
|
||||
|
||||
from ..settings import SETTINGS
|
||||
from .helpers import RedisCache, WebDAVclient, davkey
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebDAV:
|
||||
_webdav_client = WebDAVclient(
|
||||
{
|
||||
"webdav_hostname": SETTINGS.webdav.url,
|
||||
"webdav_login": SETTINGS.webdav.username,
|
||||
"webdav_password": SETTINGS.webdav.password,
|
||||
}
|
||||
)
|
||||
|
||||
_cache = RedisCache(
|
||||
cache=Redis(
|
||||
host=SETTINGS.redis.host,
|
||||
port=SETTINGS.redis.port,
|
||||
db=SETTINGS.redis.db,
|
||||
protocol=SETTINGS.redis.protocol,
|
||||
),
|
||||
ttl=SETTINGS.webdav.cache_ttl,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@asyncify
|
||||
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("list_files"))
|
||||
def list_files(
|
||||
cls,
|
||||
directory: str = "",
|
||||
*,
|
||||
regex: re.Pattern[str] = re.compile(""),
|
||||
) -> list[str]:
|
||||
"""
|
||||
List files in directory `directory` matching RegEx `regex`
|
||||
"""
|
||||
|
||||
_logger.debug(f"list_files {directory!r}")
|
||||
ls = cls._webdav_client.list(directory)
|
||||
|
||||
return [path for path in ls if regex.search(path)]
|
||||
|
||||
@classmethod
|
||||
@asyncify
|
||||
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("exists"))
|
||||
def exists(cls, path: str) -> bool:
|
||||
"""
|
||||
`True` iff there is a WebDAV resource at `path`
|
||||
"""
|
||||
|
||||
_logger.debug(f"file_exists {path!r}")
|
||||
return cls._webdav_client.check(path)
|
||||
|
||||
@classmethod
|
||||
@asyncify
|
||||
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("read_bytes"))
|
||||
def read_bytes(cls, path: str) -> bytes:
|
||||
"""
|
||||
Load WebDAV file from `path` as bytes
|
||||
"""
|
||||
|
||||
_logger.debug(f"read_bytes {path!r}")
|
||||
buffer = BytesIO()
|
||||
cls._webdav_client.download_from(buffer, path)
|
||||
buffer.seek(0)
|
||||
|
||||
return buffer.read()
|
||||
|
||||
@classmethod
|
||||
async def read_str(cls, path: str, encoding="utf-8") -> str:
|
||||
"""
|
||||
Load WebDAV file from `path` as string
|
||||
"""
|
||||
|
||||
_logger.debug(f"read_str {path!r}")
|
||||
return (await cls.read_bytes(path)).decode(encoding=encoding).strip()
|
||||
|
||||
@classmethod
|
||||
@asyncify
|
||||
def write_bytes(cls, path: str, buffer: bytes) -> None:
|
||||
"""
|
||||
Write bytes from `buffer` into WebDAV file at `path`
|
||||
"""
|
||||
|
||||
_logger.debug(f"write_bytes {path!r}")
|
||||
cls._webdav_client.upload_to(buffer, path)
|
||||
|
||||
# invalidate cache entry
|
||||
# explicit slice as there is no "cls" argument
|
||||
del cls._cache[davkey("read_bytes", slice(0, None))(path)]
|
||||
|
||||
@classmethod
|
||||
async def write_str(cls, path: str, content: str, encoding="utf-8") -> None:
|
||||
"""
|
||||
Write string from `content` into WebDAV file at `path`
|
||||
"""
|
||||
|
||||
_logger.debug(f"write_str {path!r}")
|
||||
await cls.write_bytes(path, content.encode(encoding=encoding))
|
214
api/advent22_api/core/depends.py
Normal file
214
api/advent22_api/core/depends.py
Normal file
|
@ -0,0 +1,214 @@
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from io import BytesIO
|
||||
from typing import cast
|
||||
|
||||
from fastapi import Depends
|
||||
from PIL import Image, ImageFont
|
||||
|
||||
from .advent_image import _XY, AdventImage
|
||||
from .calendar_config import CalendarConfig, get_calendar_config
|
||||
from .config import Config, get_config
|
||||
from .dav.webdav import WebDAV
|
||||
from .helpers import (
|
||||
RE_TTF,
|
||||
EventDates,
|
||||
Random,
|
||||
list_fonts,
|
||||
list_images_auto,
|
||||
list_images_manual,
|
||||
load_image,
|
||||
set_len,
|
||||
)
|
||||
|
||||
|
||||
async def get_all_sorted_days(
|
||||
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||
) -> list[int]:
|
||||
"""
|
||||
Alle Tage, für die es ein Türchen gibt
|
||||
"""
|
||||
|
||||
return sorted(set(door.day for door in cal_cfg.doors))
|
||||
|
||||
|
||||
async def get_all_parts(
|
||||
cfg: Config = Depends(get_config),
|
||||
days: list[int] = Depends(get_all_sorted_days),
|
||||
) -> dict[int, str]:
|
||||
"""
|
||||
Lösung auf vorhandene Tage aufteilen
|
||||
"""
|
||||
|
||||
# noch keine Buchstaben verteilt
|
||||
result = {day: "" for day in days}
|
||||
# extra-Tage ausfiltern
|
||||
days = [day for day in days if day not in cfg.puzzle.extra_days]
|
||||
|
||||
solution_length = len(cfg.solution.clean)
|
||||
num_days = len(days)
|
||||
|
||||
rnd = await Random.get()
|
||||
solution_days = [
|
||||
# wie oft passen die Tage "ganz" in die Länge der Lösung?
|
||||
# zB 26 Buchstaben // 10 Tage == 2 mal => 2 Zeichen pro Tag
|
||||
*rnd.shuffled(days * (solution_length // num_days)),
|
||||
# wie viele Buchstaben bleiben übrig?
|
||||
# zB 26 % 10 == 6 Buchstaben => an 6 Tagen ein Zeichen mehr
|
||||
*rnd.sample(days, solution_length % num_days),
|
||||
]
|
||||
|
||||
for day, letter in zip(solution_days, cfg.solution.clean):
|
||||
result[day] += letter
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def get_all_event_dates(
|
||||
cfg: Config = Depends(get_config),
|
||||
days: list[int] = Depends(get_all_sorted_days),
|
||||
parts: dict[int, str] = Depends(get_all_parts),
|
||||
) -> EventDates:
|
||||
"""
|
||||
Aktueller Kalender-Zeitraum
|
||||
"""
|
||||
|
||||
if cfg.puzzle.skip_empty:
|
||||
days = [day for day in days if parts[day] != "" or day in cfg.puzzle.extra_days]
|
||||
|
||||
return EventDates(
|
||||
today=date.today(),
|
||||
begin_month=cfg.puzzle.begin_month,
|
||||
begin_day=cfg.puzzle.begin_day,
|
||||
events=days,
|
||||
close_after=cfg.puzzle.close_after,
|
||||
)
|
||||
|
||||
|
||||
async def get_all_auto_image_names(
|
||||
days: list[int] = Depends(get_all_sorted_days),
|
||||
images: list[str] = Depends(list_images_auto),
|
||||
) -> dict[int, str]:
|
||||
"""
|
||||
Bilder: Reihenfolge zufällig bestimmen
|
||||
"""
|
||||
|
||||
rnd = await Random.get()
|
||||
ls = set_len(images, len(days))
|
||||
|
||||
return dict(zip(days, rnd.shuffled(ls)))
|
||||
|
||||
|
||||
async def get_all_image_names(
|
||||
auto_image_names: dict[int, str] = Depends(get_all_auto_image_names),
|
||||
manual_image_names: list[str] = Depends(list_images_manual),
|
||||
) -> dict[int, str]:
|
||||
"""
|
||||
Bilder "auto" und "manual" zu Tagen zuordnen
|
||||
"""
|
||||
|
||||
num_re = re.compile(r"/(\d+)\.", flags=re.IGNORECASE)
|
||||
|
||||
for name in manual_image_names:
|
||||
assert (num_match := num_re.search(name)) is not None
|
||||
auto_image_names[int(num_match.group(1))] = name
|
||||
|
||||
return auto_image_names
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class TTFont:
|
||||
# Dateiname
|
||||
file_name: str
|
||||
|
||||
# Schriftgröße für den Font
|
||||
size: int = 50
|
||||
|
||||
@property
|
||||
async def font(self) -> "ImageFont._Font":
|
||||
return ImageFont.truetype(
|
||||
font=BytesIO(await WebDAV.read_bytes(self.file_name)),
|
||||
size=100,
|
||||
)
|
||||
|
||||
|
||||
async def get_all_ttfonts(
|
||||
font_names: list[str] = Depends(list_fonts),
|
||||
) -> list[TTFont]:
|
||||
result = []
|
||||
|
||||
for name in font_names:
|
||||
assert (size_match := RE_TTF.search(name)) is not None
|
||||
|
||||
result.append(
|
||||
TTFont(
|
||||
file_name=name,
|
||||
size=int(size_match.group(1)),
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def gen_day_auto_image(
|
||||
day: int,
|
||||
cfg: Config,
|
||||
auto_image_names: dict[int, str],
|
||||
day_parts: dict[int, str],
|
||||
ttfonts: list[TTFont],
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Automatisch generiertes Bild erstellen
|
||||
"""
|
||||
|
||||
# Datei existiert garantiert!
|
||||
img = await load_image(auto_image_names[day])
|
||||
image = await AdventImage.from_img(img, cfg)
|
||||
|
||||
rnd = await Random.get(day)
|
||||
xy_range = range(cfg.image.border, (cfg.image.size - cfg.image.border))
|
||||
|
||||
# Buchstaben verstecken
|
||||
for letter in day_parts[day]:
|
||||
await image.hide_text(
|
||||
xy=cast(_XY, tuple(rnd.choices(xy_range, k=2))),
|
||||
text=letter,
|
||||
font=await rnd.choice(ttfonts).font,
|
||||
)
|
||||
|
||||
return image.img
|
||||
|
||||
|
||||
async def get_day_image(
|
||||
day: int,
|
||||
days: list[int] = Depends(get_all_sorted_days),
|
||||
cfg: Config = Depends(get_config),
|
||||
auto_image_names: dict[int, str] = Depends(get_all_auto_image_names),
|
||||
day_parts: dict[int, str] = Depends(get_all_parts),
|
||||
ttfonts: list[TTFont] = Depends(get_all_ttfonts),
|
||||
) -> Image.Image | None:
|
||||
"""
|
||||
Bild für einen Tag abrufen
|
||||
"""
|
||||
|
||||
if day not in days:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Versuche, aus "manual"-Ordner zu laden
|
||||
img = await load_image(f"images_manual/{day}.jpg")
|
||||
|
||||
# Als AdventImage verarbeiten
|
||||
image = await AdventImage.from_img(img, cfg)
|
||||
return image.img
|
||||
|
||||
except RuntimeError:
|
||||
# Erstelle automatisch generiertes Bild
|
||||
return await gen_day_auto_image(
|
||||
day=day,
|
||||
cfg=cfg,
|
||||
auto_image_names=auto_image_names,
|
||||
day_parts=day_parts,
|
||||
ttfonts=ttfonts,
|
||||
)
|
216
api/advent22_api/core/helpers.py
Normal file
216
api/advent22_api/core/helpers.py
Normal file
|
@ -0,0 +1,216 @@
|
|||
import itertools
|
||||
import random
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from io import BytesIO
|
||||
from typing import Any, Awaitable, Callable, Iterable, Self, Sequence, TypeVar
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
from PIL import Image
|
||||
|
||||
from .config import get_config
|
||||
from .dav.webdav import WebDAV
|
||||
|
||||
T = TypeVar("T")
|
||||
RE_IMG = re.compile(r"\.(gif|jpe?g|tiff?|png|bmp)$", flags=re.IGNORECASE)
|
||||
RE_TTF = re.compile(r"_(\d+)\.ttf$", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
class Random(random.Random):
|
||||
@classmethod
|
||||
async def get(cls, bonus_salt: Any = "") -> Self:
|
||||
cfg = await get_config()
|
||||
return cls(f"{cfg.solution.clean}{cfg.random_seed}{bonus_salt}")
|
||||
|
||||
def shuffled(self, population: Sequence[T]) -> Sequence[T]:
|
||||
return self.sample(population, k=len(population))
|
||||
|
||||
|
||||
def set_len(seq: Sequence[T], len: int) -> Sequence[T]:
|
||||
# `seq` unendlich wiederholen
|
||||
infinite = itertools.cycle(seq)
|
||||
|
||||
# Die ersten `length` einträge nehmen
|
||||
return list(itertools.islice(infinite, len))
|
||||
|
||||
|
||||
def spread(
|
||||
given: Iterable[int],
|
||||
n: int,
|
||||
rnd: Random | None = None,
|
||||
) -> list[int]:
|
||||
"""
|
||||
Zu `given` ganzen Zahlen `n` zusätzliche Zahlen hinzunehmen.
|
||||
|
||||
- Die neuen Werte sind im selben Zahlenbereich wie `given`
|
||||
- Zuerst werden alle Werte "zwischen" den `given` Werten genommen
|
||||
"""
|
||||
|
||||
if n == 0:
|
||||
return []
|
||||
|
||||
if len(set(given)) > 1:
|
||||
range_given = range(min(given), max(given) + 1)
|
||||
first_round = set(range_given) - set(given)
|
||||
|
||||
elif len(set(given)) == 1:
|
||||
if (a := next(iter(given))) > 0:
|
||||
range_given = range(1, a + 1)
|
||||
else:
|
||||
range_given = range(1, n + 1)
|
||||
|
||||
first_round = set(range_given) - set(given)
|
||||
|
||||
else:
|
||||
range_given = range(1, n + 1)
|
||||
first_round = range_given
|
||||
|
||||
result = sorted(first_round)[: min(n, len(first_round))]
|
||||
|
||||
full_rounds = (n - len(result)) // len(range_given)
|
||||
result += list(range_given) * full_rounds
|
||||
|
||||
remain = n - len(result)
|
||||
if rnd is None:
|
||||
result += list(range_given)[:remain]
|
||||
|
||||
else:
|
||||
result += rnd.sample(range_given, remain)
|
||||
rnd.shuffle(result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def list_helper(
|
||||
directory: str,
|
||||
regex: re.Pattern[str],
|
||||
) -> Callable[[], Awaitable[list[str]]]:
|
||||
"""
|
||||
Finde alle Dateien im Verzeichnis `dir`, passend zu `re`
|
||||
"""
|
||||
|
||||
async def _list_helper() -> list[str]:
|
||||
return [
|
||||
f"{directory}/{file}"
|
||||
for file in await WebDAV.list_files(directory=directory, regex=regex)
|
||||
]
|
||||
|
||||
return _list_helper
|
||||
|
||||
|
||||
list_images_auto = list_helper("/images_auto", RE_IMG)
|
||||
list_images_manual = list_helper("/images_manual", RE_IMG)
|
||||
list_fonts = list_helper("/files", RE_TTF)
|
||||
|
||||
|
||||
async def load_image(file_name: str) -> Image.Image:
|
||||
"""
|
||||
Versuche, Bild aus Datei zu laden
|
||||
"""
|
||||
|
||||
if not await WebDAV.exists(file_name):
|
||||
raise RuntimeError(f"DAV-File {file_name} does not exist!")
|
||||
|
||||
return Image.open(BytesIO(await WebDAV.read_bytes(file_name)))
|
||||
|
||||
|
||||
async def api_return_ico(img: Image.Image) -> StreamingResponse:
|
||||
"""
|
||||
ICO-Bild mit API zurückgeben
|
||||
"""
|
||||
|
||||
# JPEG-Daten in Puffer speichern
|
||||
img_buffer = BytesIO()
|
||||
img.resize(size=(256, 256), resample=Image.LANCZOS)
|
||||
img.save(img_buffer, format="ICO")
|
||||
img_buffer.seek(0)
|
||||
|
||||
# zurückgeben
|
||||
return StreamingResponse(
|
||||
media_type="image/x-icon",
|
||||
content=img_buffer,
|
||||
)
|
||||
|
||||
|
||||
async def api_return_jpeg(img: Image.Image) -> StreamingResponse:
|
||||
"""
|
||||
JPEG-Bild mit API zurückgeben
|
||||
"""
|
||||
|
||||
# JPEG-Daten in Puffer speichern
|
||||
img_buffer = BytesIO()
|
||||
img.save(img_buffer, format="JPEG", quality=85)
|
||||
img_buffer.seek(0)
|
||||
|
||||
# zurückgeben
|
||||
return StreamingResponse(
|
||||
media_type="image/jpeg",
|
||||
content=img_buffer,
|
||||
)
|
||||
|
||||
|
||||
class EventDates:
|
||||
"""
|
||||
Events in einem Ereigniszeitraum
|
||||
"""
|
||||
|
||||
__overall_duration: timedelta
|
||||
dates: dict[int, date]
|
||||
|
||||
@property
|
||||
def first(self) -> date:
|
||||
"""Datum des ersten Ereignisses"""
|
||||
return self.dates[min(self.dates.keys())]
|
||||
|
||||
def get_next(self, *, today: date) -> date | None:
|
||||
"""Datum des nächsten Ereignisses"""
|
||||
return next(
|
||||
(event for event in sorted(self.dates.values()) if event > today), None
|
||||
)
|
||||
|
||||
@property
|
||||
def next(self) -> date | None:
|
||||
"""Datum des nächsten Ereignisses"""
|
||||
return self.get_next(today=date.today())
|
||||
|
||||
@property
|
||||
def last(self) -> date:
|
||||
"""Datum des letzten Ereignisses"""
|
||||
return self.dates[max(self.dates.keys())]
|
||||
|
||||
@property
|
||||
def end(self) -> date:
|
||||
"""Letztes Datum des Ereigniszeitraums"""
|
||||
return self.first + self.__overall_duration
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
# current date
|
||||
today: date,
|
||||
# month/day when events begin
|
||||
begin_month: int,
|
||||
begin_day: int,
|
||||
# events: e.g. a 2 means there is an event on the 2nd day
|
||||
# i.e. 1 day after begin
|
||||
# - assume sorted (ascending)
|
||||
events: list[int],
|
||||
# countdown to closing begins after last event
|
||||
close_after: int,
|
||||
) -> None:
|
||||
# account for the last event, then add closing period
|
||||
self.__overall_duration = timedelta(days=events[-1] - 1 + close_after)
|
||||
|
||||
# the events may begin last year, this year or next year
|
||||
maybe_begin = (
|
||||
datetime(today.year + year_diff, begin_month, begin_day).date()
|
||||
for year_diff in (-1, 0, +1)
|
||||
)
|
||||
|
||||
# find the first begin where the end date is in the future
|
||||
begin = next(
|
||||
begin for begin in maybe_begin if today <= (begin + self.__overall_duration)
|
||||
)
|
||||
|
||||
# all event dates
|
||||
self.dates = {event: begin + timedelta(days=event - 1) for event in events}
|
93
api/advent22_api/core/settings.py
Normal file
93
api/advent22_api/core/settings.py
Normal file
|
@ -0,0 +1,93 @@
|
|||
from typing import TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class DavSettings(BaseModel):
|
||||
"""
|
||||
Connection to a DAV server.
|
||||
"""
|
||||
|
||||
protocol: str = "https"
|
||||
host: str = "example.com"
|
||||
path: str = "/remote.php/webdav"
|
||||
prefix: str = "/advent22"
|
||||
|
||||
username: str = "advent22_user"
|
||||
password: str = "password"
|
||||
|
||||
cache_ttl: int = 60 * 10
|
||||
config_filename: str = "config.toml"
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""
|
||||
Combined DAV URL.
|
||||
"""
|
||||
|
||||
return f"{self.protocol}://{self.host}{self.path}{self.prefix}"
|
||||
|
||||
|
||||
class RedisSettings(BaseModel):
|
||||
"""
|
||||
Connection to a redis server.
|
||||
"""
|
||||
|
||||
host: str = "localhost"
|
||||
port: int = 6379
|
||||
db: int = 0
|
||||
protocol: int = 3
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""
|
||||
Per-run settings.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file="api.conf",
|
||||
env_file_encoding="utf-8",
|
||||
env_nested_delimiter="__",
|
||||
)
|
||||
|
||||
#####
|
||||
# general settings
|
||||
#####
|
||||
|
||||
production_mode: bool = False
|
||||
ui_directory: str = "/usr/local/share/advent22_ui/html"
|
||||
|
||||
#####
|
||||
# openapi settings
|
||||
#####
|
||||
|
||||
def __dev_value(self, value: T) -> T | None:
|
||||
if self.production_mode:
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
@property
|
||||
def openapi_url(self) -> str | None:
|
||||
return self.__dev_value("/api/openapi.json")
|
||||
|
||||
@property
|
||||
def docs_url(self) -> str | None:
|
||||
return self.__dev_value("/api/docs")
|
||||
|
||||
@property
|
||||
def redoc_url(self) -> str | None:
|
||||
return self.__dev_value("/api/redoc")
|
||||
|
||||
#####
|
||||
# webdav settings
|
||||
#####
|
||||
|
||||
webdav: DavSettings = DavSettings()
|
||||
redis: RedisSettings = RedisSettings()
|
||||
|
||||
|
||||
SETTINGS = Settings()
|
96
api/advent22_api/core/transformed_string.py
Normal file
96
api/advent22_api/core/transformed_string.py
Normal file
|
@ -0,0 +1,96 @@
|
|||
import re
|
||||
from enum import Enum
|
||||
from random import Random
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
RE_WHITESPACE = re.compile(
|
||||
pattern=r"\s+",
|
||||
flags=re.UNICODE | re.IGNORECASE,
|
||||
)
|
||||
RE_SPECIAL_CHARS = re.compile(
|
||||
pattern=r"[^a-zA-Z0-9\s]+",
|
||||
flags=re.UNICODE | re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class TransformedString(BaseModel):
|
||||
class __Whitespace(str, Enum):
|
||||
# unverändert
|
||||
KEEP = "KEEP"
|
||||
|
||||
# Leerzeichen an Anfang und Ende entfernen
|
||||
STRIP = "STRIP"
|
||||
|
||||
# whitespace durch Leerzeichen ersetzen
|
||||
SPACE = "SPACE"
|
||||
|
||||
# whitespace entfernen
|
||||
REMOVE = "REMOVE"
|
||||
|
||||
class __SpecialChars(str, Enum):
|
||||
# unverändert
|
||||
KEEP = "KEEP"
|
||||
|
||||
# Sonderzeichen entfernen
|
||||
REMOVE = "REMOVE"
|
||||
|
||||
class __Case(str, Enum):
|
||||
# unverändert
|
||||
KEEP = "KEEP"
|
||||
|
||||
# GROSSBUCHSTABEN
|
||||
UPPER = "UPPER"
|
||||
|
||||
# kleinbuchstaben
|
||||
LOWER = "LOWER"
|
||||
|
||||
# ZuFÄllIg
|
||||
RANDOM = "RANDOM"
|
||||
|
||||
value: str
|
||||
|
||||
whitespace: __Whitespace = __Whitespace.REMOVE
|
||||
special_chars: __SpecialChars = __SpecialChars.REMOVE
|
||||
case: __Case = __Case.UPPER
|
||||
|
||||
@field_validator("whitespace", "case", mode="before")
|
||||
def transform_from_str(cls, v) -> str:
|
||||
return str(v).upper()
|
||||
|
||||
@property
|
||||
def clean(self) -> str:
|
||||
result = self.value
|
||||
|
||||
# Whitespace verarbeiten
|
||||
if self.whitespace is TransformedString.__Whitespace.STRIP:
|
||||
result = result.strip()
|
||||
|
||||
elif self.whitespace is TransformedString.__Whitespace.SPACE:
|
||||
result = RE_WHITESPACE.sub(string=result, repl=" ")
|
||||
|
||||
elif self.whitespace is TransformedString.__Whitespace.REMOVE:
|
||||
result = RE_WHITESPACE.sub(string=result, repl="")
|
||||
|
||||
# Sonderzeichen verarbeiten
|
||||
if self.special_chars is TransformedString.__SpecialChars.REMOVE:
|
||||
result = RE_SPECIAL_CHARS.sub(string=result, repl="")
|
||||
|
||||
# Groß-/Kleinschreibung verarbeiten
|
||||
if self.case is TransformedString.__Case.UPPER:
|
||||
result = result.upper()
|
||||
|
||||
elif self.case is TransformedString.__Case.LOWER:
|
||||
result = result.lower()
|
||||
|
||||
elif self.case is TransformedString.__Case.RANDOM:
|
||||
rnd = Random(self.value)
|
||||
|
||||
def randomcase(c: str) -> str:
|
||||
if rnd.choice((True, False)):
|
||||
return c.upper()
|
||||
return c.lower()
|
||||
|
||||
result = "".join(randomcase(c) for c in result)
|
||||
|
||||
return result
|
22
api/advent22_api/main.py
Normal file
22
api/advent22_api/main.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/python3
|
||||
|
||||
import uvicorn
|
||||
|
||||
from .core.settings import SETTINGS
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
If the `main` script is run, `uvicorn` is used to run the app.
|
||||
"""
|
||||
|
||||
uvicorn.run(
|
||||
app="advent22_api.app:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=not SETTINGS.production_mode,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
8
api/advent22_api/routers/__init__.py
Normal file
8
api/advent22_api/routers/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from . import admin, user
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
router.include_router(admin.router)
|
||||
router.include_router(user.router)
|
65
api/advent22_api/routers/_security.py
Normal file
65
api/advent22_api/routers/_security.py
Normal file
|
@ -0,0 +1,65 @@
|
|||
import secrets
|
||||
from datetime import date
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
|
||||
from ..core.config import Config, get_config
|
||||
from ..core.depends import get_all_event_dates
|
||||
from ..core.helpers import EventDates
|
||||
|
||||
security = HTTPBasic()
|
||||
|
||||
|
||||
async def user_is_admin(
|
||||
credentials: HTTPBasicCredentials = Depends(security),
|
||||
cfg: Config = Depends(get_config),
|
||||
) -> bool:
|
||||
"""
|
||||
True iff der user "admin" ist
|
||||
"""
|
||||
|
||||
username_correct = secrets.compare_digest(
|
||||
credentials.username.lower(),
|
||||
cfg.admin.name.lower(),
|
||||
)
|
||||
password_correct = secrets.compare_digest(
|
||||
credentials.password,
|
||||
cfg.admin.password,
|
||||
)
|
||||
|
||||
return username_correct and password_correct
|
||||
|
||||
|
||||
async def require_admin(
|
||||
is_admin: bool = Depends(user_is_admin),
|
||||
) -> None:
|
||||
"""
|
||||
HTTP 401 iff der user nicht "admin" ist
|
||||
"""
|
||||
|
||||
if not is_admin:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wie unhöflich!!!")
|
||||
|
||||
|
||||
async def user_visible_days(
|
||||
event_dates: EventDates = Depends(get_all_event_dates),
|
||||
) -> list[int]:
|
||||
"""
|
||||
User-sichtbare Türchen
|
||||
"""
|
||||
|
||||
today = date.today()
|
||||
|
||||
return [event for event, date in event_dates.dates.items() if date <= today]
|
||||
|
||||
|
||||
async def user_can_view_day(
|
||||
day: int,
|
||||
visible_days: list[int] = Depends(user_visible_days),
|
||||
) -> bool:
|
||||
"""
|
||||
True iff das Türchen von Tag `day` user-sichtbar ist
|
||||
"""
|
||||
|
||||
return day in visible_days
|
193
api/advent22_api/routers/admin.py
Normal file
193
api/advent22_api/routers/admin.py
Normal file
|
@ -0,0 +1,193 @@
|
|||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from advent22_api.core.helpers import EventDates
|
||||
|
||||
from ..core.calendar_config import CalendarConfig, DoorsSaved, get_calendar_config
|
||||
from ..core.config import Config, Image, get_config
|
||||
from ..core.depends import (
|
||||
TTFont,
|
||||
get_all_event_dates,
|
||||
get_all_image_names,
|
||||
get_all_parts,
|
||||
get_all_ttfonts,
|
||||
)
|
||||
from ..core.settings import SETTINGS, RedisSettings
|
||||
from ._security import require_admin, user_is_admin
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/is_admin")
|
||||
async def is_admin(
|
||||
is_admin: bool = Depends(user_is_admin),
|
||||
) -> bool:
|
||||
return is_admin
|
||||
|
||||
|
||||
class AdminConfigModel(BaseModel):
|
||||
class __Solution(BaseModel):
|
||||
value: str
|
||||
whitespace: str
|
||||
special_chars: str
|
||||
case: str
|
||||
clean: str
|
||||
|
||||
class __Puzzle(BaseModel):
|
||||
first: date
|
||||
next: date | None
|
||||
last: date
|
||||
end: date
|
||||
seed: str
|
||||
extra_days: list[int]
|
||||
skip_empty: bool
|
||||
|
||||
class __Calendar(BaseModel):
|
||||
config_file: str
|
||||
background: str
|
||||
favicon: str
|
||||
|
||||
class __Font(BaseModel):
|
||||
file: str
|
||||
size: int
|
||||
|
||||
class __WebDAV(BaseModel):
|
||||
url: str
|
||||
cache_ttl: int
|
||||
config_file: str
|
||||
|
||||
solution: __Solution
|
||||
puzzle: __Puzzle
|
||||
calendar: __Calendar
|
||||
image: Image
|
||||
fonts: list[__Font]
|
||||
redis: RedisSettings
|
||||
webdav: __WebDAV
|
||||
|
||||
|
||||
@router.get("/config_model")
|
||||
async def get_config_model(
|
||||
_: None = Depends(require_admin),
|
||||
cfg: Config = Depends(get_config),
|
||||
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||
event_dates: EventDates = Depends(get_all_event_dates),
|
||||
ttfonts: list[TTFont] = Depends(get_all_ttfonts),
|
||||
) -> AdminConfigModel:
|
||||
"""
|
||||
Kombiniert aus privaten `settings`, `config` und `calendar_config`
|
||||
"""
|
||||
|
||||
return AdminConfigModel.model_validate(
|
||||
{
|
||||
"solution": {
|
||||
"value": cfg.solution.value,
|
||||
"whitespace": cfg.solution.whitespace,
|
||||
"special_chars": cfg.solution.special_chars,
|
||||
"case": cfg.solution.case,
|
||||
"clean": cfg.solution.clean,
|
||||
},
|
||||
"puzzle": {
|
||||
"first": event_dates.first,
|
||||
"next": event_dates.next,
|
||||
"last": event_dates.last,
|
||||
"end": event_dates.end,
|
||||
"seed": cfg.random_seed,
|
||||
"extra_days": sorted(cfg.puzzle.extra_days),
|
||||
"skip_empty": cfg.puzzle.skip_empty,
|
||||
},
|
||||
"calendar": {
|
||||
"config_file": cfg.calendar,
|
||||
"background": cal_cfg.background,
|
||||
"favicon": cal_cfg.favicon,
|
||||
},
|
||||
"image": cfg.image,
|
||||
"fonts": [
|
||||
{"file": ttfont.file_name, "size": ttfont.size} for ttfont in ttfonts
|
||||
],
|
||||
"redis": SETTINGS.redis,
|
||||
"webdav": {
|
||||
"url": SETTINGS.webdav.url,
|
||||
"cache_ttl": SETTINGS.webdav.cache_ttl,
|
||||
"config_file": SETTINGS.webdav.config_filename,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/day_image_names")
|
||||
async def get_day_image_names(
|
||||
_: None = Depends(require_admin),
|
||||
image_names: dict[int, str] = Depends(get_all_image_names),
|
||||
) -> dict[int, str]:
|
||||
"""
|
||||
Zuordnung der verwendeten Bilder zu den Tagen
|
||||
"""
|
||||
|
||||
return image_names
|
||||
|
||||
|
||||
@router.get("/day_parts")
|
||||
async def get_day_parts(
|
||||
_: None = Depends(require_admin),
|
||||
parts: dict[int, str] = Depends(get_all_parts),
|
||||
) -> dict[int, str]:
|
||||
"""
|
||||
Zuordnung der Lösungsteile zu den Tagen
|
||||
"""
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
@router.get("/doors")
|
||||
async def get_doors(
|
||||
_: None = Depends(require_admin),
|
||||
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||
) -> DoorsSaved:
|
||||
"""
|
||||
Türchen lesen
|
||||
"""
|
||||
|
||||
return cal_cfg.doors
|
||||
|
||||
|
||||
@router.put("/doors")
|
||||
async def put_doors(
|
||||
doors: DoorsSaved,
|
||||
_: None = Depends(require_admin),
|
||||
cfg: Config = Depends(get_config),
|
||||
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||
) -> None:
|
||||
"""
|
||||
Türchen ändern
|
||||
"""
|
||||
|
||||
cal_cfg.doors = sorted(
|
||||
doors,
|
||||
key=lambda door: door.day,
|
||||
)
|
||||
await cal_cfg.change(cfg)
|
||||
|
||||
|
||||
@router.get("/dav_credentials")
|
||||
async def get_dav_credentials(
|
||||
_: None = Depends(require_admin),
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Zugangsdaten für WebDAV
|
||||
"""
|
||||
|
||||
return SETTINGS.webdav.username, SETTINGS.webdav.password
|
||||
|
||||
|
||||
@router.get("/ui_credentials")
|
||||
async def get_ui_credentials(
|
||||
_: None = Depends(require_admin),
|
||||
cfg: Config = Depends(get_config),
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Zugangsdaten für Admin-UI
|
||||
"""
|
||||
|
||||
return cfg.admin.name, cfg.admin.password
|
109
api/advent22_api/routers/user.py
Normal file
109
api/advent22_api/routers/user.py
Normal file
|
@ -0,0 +1,109 @@
|
|||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from PIL import Image
|
||||
|
||||
from ..core.calendar_config import CalendarConfig, DoorsSaved, get_calendar_config
|
||||
from ..core.config import Config, Site, get_config
|
||||
from ..core.depends import get_all_event_dates, get_day_image
|
||||
from ..core.helpers import EventDates, api_return_ico, api_return_jpeg, load_image
|
||||
from ._security import user_can_view_day, user_is_admin, user_visible_days
|
||||
|
||||
router = APIRouter(prefix="/user", tags=["user"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/background_image",
|
||||
response_class=StreamingResponse,
|
||||
)
|
||||
async def get_background_image(
|
||||
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
Hintergrundbild laden
|
||||
"""
|
||||
|
||||
return await api_return_jpeg(await load_image(f"files/{cal_cfg.background}"))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/favicon",
|
||||
response_class=StreamingResponse,
|
||||
)
|
||||
async def get_favicon(
|
||||
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
Favicon laden
|
||||
"""
|
||||
|
||||
try:
|
||||
return await api_return_ico(await load_image(f"files/{cal_cfg.favicon}"))
|
||||
|
||||
except RuntimeError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
@router.get("/site_config")
|
||||
async def get_site_config(
|
||||
cfg: Config = Depends(get_config),
|
||||
) -> Site:
|
||||
"""
|
||||
Seiteninhalt
|
||||
"""
|
||||
|
||||
return cfg.site
|
||||
|
||||
|
||||
@router.get("/doors")
|
||||
async def get_doors(
|
||||
cal_cfg: CalendarConfig = Depends(get_calendar_config),
|
||||
visible_days: list[int] = Depends(user_visible_days),
|
||||
) -> DoorsSaved:
|
||||
"""
|
||||
User-sichtbare Türchen lesen
|
||||
"""
|
||||
|
||||
return [door for door in cal_cfg.doors if door.day in visible_days]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/image_{day}",
|
||||
response_class=StreamingResponse,
|
||||
)
|
||||
async def get_image_for_day(
|
||||
user_can_view: bool = Depends(user_can_view_day),
|
||||
is_admin: bool = Depends(user_is_admin),
|
||||
image: Image.Image | None = Depends(get_day_image),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
Bild für einen Tag erstellen
|
||||
"""
|
||||
|
||||
if not (user_can_view or is_admin):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wie unhöflich!!!")
|
||||
|
||||
if image is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, "Ich habe heute leider kein Foto für dich."
|
||||
)
|
||||
|
||||
return await api_return_jpeg(image)
|
||||
|
||||
|
||||
@router.get("/next_door")
|
||||
async def get_next_door(
|
||||
event_dates: EventDates = Depends(get_all_event_dates),
|
||||
) -> int | None:
|
||||
"""
|
||||
Zeit in ms, bis das nächste Türchen öffnet
|
||||
"""
|
||||
|
||||
if event_dates.next is None:
|
||||
return None
|
||||
|
||||
dt = datetime.combine(event_dates.next, datetime.min.time())
|
||||
td = dt - datetime.now()
|
||||
|
||||
return int(td.total_seconds() * 1000)
|
1475
api/poetry.lock
generated
Normal file
1475
api/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
33
api/pyproject.toml
Normal file
33
api/pyproject.toml
Normal file
|
@ -0,0 +1,33 @@
|
|||
[tool.poetry]
|
||||
authors = [
|
||||
"Jörn-Michael Miehe <jmm@yavook.de>",
|
||||
"Penner42 <unbekannt42@web.de>",
|
||||
]
|
||||
description = ""
|
||||
license = "MIT"
|
||||
name = "advent22_api"
|
||||
version = "0.1.0"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
Pillow = "^10.2.0"
|
||||
asyncify = "^0.9.2"
|
||||
cachetools = "^5.3.3"
|
||||
cachetoolsutils = "^8.5"
|
||||
fastapi = "^0.103.1"
|
||||
markdown = "^3.6"
|
||||
numpy = "^1.26.4"
|
||||
pydantic-settings = "^2.2.1"
|
||||
python = ">=3.11,<3.13"
|
||||
redis = {extras = ["hiredis"], version = "^5.0.3"}
|
||||
tomli-w = "^1.0.0"
|
||||
uvicorn = {extras = ["standard"], version = "^0.23.2"}
|
||||
webdavclient3 = "^3.14.6"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^24.3.0"
|
||||
flake8 = "^7.0.0"
|
||||
pytest = "^8.1.1"
|
||||
|
||||
[build-system]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["poetry-core>=1.0.0"]
|
88
api/test/test_event_dates.py
Normal file
88
api/test/test_event_dates.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
from datetime import date
|
||||
|
||||
from advent22_api.core.helpers import EventDates
|
||||
|
||||
|
||||
def test_get_before():
|
||||
today = date(2023, 11, 30)
|
||||
|
||||
ed = EventDates(
|
||||
today=today,
|
||||
begin_month=12,
|
||||
begin_day=1,
|
||||
events=list(range(1, 25)),
|
||||
close_after=5,
|
||||
)
|
||||
|
||||
assert ed.first == date(2023, 12, 1)
|
||||
assert ed.get_next(today=today) == date(2023, 12, 1)
|
||||
assert ed.last == date(2023, 12, 24)
|
||||
assert ed.end == date(2023, 12, 29)
|
||||
|
||||
|
||||
def test_get_after():
|
||||
today = date(2023, 12, 30)
|
||||
|
||||
ed = EventDates(
|
||||
today=today,
|
||||
begin_month=12,
|
||||
begin_day=1,
|
||||
events=list(range(1, 25)),
|
||||
close_after=5,
|
||||
)
|
||||
|
||||
assert ed.first == date(2024, 12, 1)
|
||||
assert ed.get_next(today=today) == date(2024, 12, 1)
|
||||
assert ed.last == date(2024, 12, 24)
|
||||
assert ed.end == date(2024, 12, 29)
|
||||
|
||||
|
||||
def test_get_during_events():
|
||||
today = date(2023, 12, 10)
|
||||
|
||||
ed = EventDates(
|
||||
today=today,
|
||||
begin_month=12,
|
||||
begin_day=1,
|
||||
events=list(range(1, 25)),
|
||||
close_after=5,
|
||||
)
|
||||
|
||||
assert ed.first == date(2023, 12, 1)
|
||||
assert ed.get_next(today=today) == date(2023, 12, 11)
|
||||
assert ed.last == date(2023, 12, 24)
|
||||
assert ed.end == date(2023, 12, 29)
|
||||
|
||||
|
||||
def test_get_during_closing():
|
||||
today = date(2023, 12, 29)
|
||||
|
||||
ed = EventDates(
|
||||
today=today,
|
||||
begin_month=12,
|
||||
begin_day=1,
|
||||
events=list(range(1, 25)),
|
||||
close_after=5,
|
||||
)
|
||||
|
||||
assert ed.first == date(2023, 12, 1)
|
||||
assert ed.get_next(today=today) is None
|
||||
assert ed.last == date(2023, 12, 24)
|
||||
assert ed.end == date(2023, 12, 29)
|
||||
|
||||
|
||||
def test_get_during_wrap():
|
||||
today = date(2024, 1, 1)
|
||||
|
||||
ed = EventDates(
|
||||
today=today,
|
||||
begin_month=12,
|
||||
begin_day=1,
|
||||
events=list(range(1, 25)),
|
||||
close_after=8,
|
||||
)
|
||||
|
||||
assert ed.first == date(2023, 12, 1)
|
||||
assert ed.get_next(today=today) is None
|
||||
assert ed.last == date(2023, 12, 24)
|
||||
assert ed.end == date(2024, 1, 1)
|
77
api/test/test_spread.py
Normal file
77
api/test/test_spread.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
from advent22_api.core.helpers import spread
|
||||
|
||||
|
||||
def test_easy() -> None:
|
||||
assert spread([1, 4], 0) == []
|
||||
assert spread([1, 4], 1) == [2]
|
||||
assert spread([1, 4], 2) == [2, 3]
|
||||
assert spread([1, 4], 5) == [2, 3, 1, 2, 3]
|
||||
assert spread([1, 4], 10) == [2, 3, 1, 2, 3, 4, 1, 2, 3, 4]
|
||||
|
||||
|
||||
def test_tight() -> None:
|
||||
assert spread([1, 2], 0) == []
|
||||
assert spread([1, 2], 1) == [1]
|
||||
assert spread([1, 2], 2) == [1, 2]
|
||||
assert spread([1, 2], 5) == [1, 2, 1, 2, 1]
|
||||
assert spread([1, 2], 10) == [1, 2, 1, 2, 1, 2, 1, 2, 1, 2]
|
||||
|
||||
assert spread([1, 2, 3, 4, 5], 0) == []
|
||||
assert spread([1, 2, 3, 4, 5], 1) == [1]
|
||||
assert spread([1, 2, 3, 4, 5], 2) == [1, 2]
|
||||
assert spread([1, 2, 3, 4, 5], 5) == [1, 2, 3, 4, 5]
|
||||
assert spread([1, 2, 3, 4, 5], 10) == [1, 2, 3, 4, 5, 1, 2, 3, 4, 5]
|
||||
|
||||
|
||||
def test_more_given() -> None:
|
||||
assert spread([0, 5, 10], 0) == []
|
||||
assert spread([0, 5, 10], 1) == [1]
|
||||
assert spread([0, 5, 10], 2) == [1, 2]
|
||||
assert spread([0, 5, 10], 5) == [1, 2, 3, 4, 6]
|
||||
assert spread([0, 5, 10], 10) == [1, 2, 3, 4, 6, 7, 8, 9, 0, 1]
|
||||
|
||||
assert spread([0, 1, 2, 5, 10], 0) == []
|
||||
assert spread([0, 1, 2, 5, 10], 1) == [3]
|
||||
assert spread([0, 1, 2, 5, 10], 2) == [3, 4]
|
||||
assert spread([0, 1, 2, 5, 10], 5) == [3, 4, 6, 7, 8]
|
||||
assert spread([0, 1, 2, 5, 10], 10) == [3, 4, 6, 7, 8, 9, 0, 1, 2, 3]
|
||||
|
||||
|
||||
def test_one_given() -> None:
|
||||
assert spread([0], 0) == []
|
||||
assert spread([0], 1) == [1]
|
||||
assert spread([0], 2) == [1, 2]
|
||||
assert spread([0], 5) == [1, 2, 3, 4, 5]
|
||||
assert spread([0], 10) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
assert spread([1], 0) == []
|
||||
assert spread([1], 1) == [1]
|
||||
assert spread([1], 2) == [1, 1]
|
||||
assert spread([1], 5) == [1, 1, 1, 1, 1]
|
||||
assert spread([1], 10) == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
|
||||
|
||||
assert spread([2], 0) == []
|
||||
assert spread([2], 1) == [1]
|
||||
assert spread([2], 2) == [1, 1]
|
||||
assert spread([2], 5) == [1, 1, 2, 1, 2]
|
||||
assert spread([2], 10) == [1, 1, 2, 1, 2, 1, 2, 1, 2, 1]
|
||||
|
||||
assert spread([5], 0) == []
|
||||
assert spread([5], 1) == [1]
|
||||
assert spread([5], 2) == [1, 2]
|
||||
assert spread([5], 5) == [1, 2, 3, 4, 1]
|
||||
assert spread([5], 10) == [1, 2, 3, 4, 1, 2, 3, 4, 5, 1]
|
||||
|
||||
assert spread([10], 0) == []
|
||||
assert spread([10], 1) == [1]
|
||||
assert spread([10], 2) == [1, 2]
|
||||
assert spread([10], 5) == [1, 2, 3, 4, 5]
|
||||
assert spread([10], 10) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 1]
|
||||
|
||||
|
||||
def test_none_given() -> None:
|
||||
assert spread([], 0) == []
|
||||
assert spread([], 1) == [1]
|
||||
assert spread([], 2) == [1, 2]
|
||||
assert spread([], 5) == [1, 2, 3, 4, 5]
|
||||
assert spread([], 10) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
4
ui/.browserslistrc
Normal file
4
ui/.browserslistrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
not ie 11
|
39
ui/.devcontainer/devcontainer.json
Normal file
39
ui/.devcontainer/devcontainer.json
Normal file
|
@ -0,0 +1,39 @@
|
|||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||
// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/javascript-node
|
||||
{
|
||||
"name": "Advent22 UI",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/vscode/devcontainers/javascript-node:1-18-bookworm",
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
"features": {
|
||||
"ghcr.io/devcontainers-contrib/features/apt-get-packages:1": {
|
||||
"packages": "git-flow, git-lfs"
|
||||
},
|
||||
"ghcr.io/devcontainers-contrib/features/vue-cli:2": {}
|
||||
},
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"terminal.integrated.defaultProfile.linux": "zsh"
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"mhutchie.git-graph",
|
||||
"Syler.sass-indented",
|
||||
"Vue.volar"
|
||||
]
|
||||
}
|
||||
},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "yarn install",
|
||||
"postStartCommand": "yarn install --production false",
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "node"
|
||||
}
|
34
ui/.eslintrc.js
Normal file
34
ui/.eslintrc.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
|
||||
'extends': [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/typescript/recommended'
|
||||
],
|
||||
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
|
||||
},
|
||||
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'**/__tests__/*.{j,t}s?(x)',
|
||||
'**/tests/unit/**/*.spec.{j,t}s?(x)'
|
||||
],
|
||||
env: {
|
||||
mocha: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
23
ui/.gitignore
vendored
Normal file
23
ui/.gitignore
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
# .vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
5
ui/.vscode/extensions.json
vendored
Normal file
5
ui/.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"sdras.vue-vscode-snippets"
|
||||
]
|
||||
}
|
15
ui/.vscode/launch.json
vendored
Normal file
15
ui/.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
// Verwendet IntelliSense zum Ermitteln möglicher Attribute.
|
||||
// Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen.
|
||||
// Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Chrome mit Advent22 UI starten",
|
||||
"url": "http://localhost:8080",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
22
ui/.vscode/settings.json
vendored
Normal file
22
ui/.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
"git.closeDiffOnOperation": true,
|
||||
"editor.tabSize": 2,
|
||||
"sass.disableAutoIndent": true,
|
||||
"sass.format.convert": false,
|
||||
"sass.format.deleteWhitespace": true,
|
||||
"prettier.trailingComma": "all",
|
||||
"volar.inlayHints.eventArgumentInInlineHandlers": false,
|
||||
}
|
12
ui/.vscode/tasks.json
vendored
Normal file
12
ui/.vscode/tasks.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "serve",
|
||||
"problemMatcher": [],
|
||||
"label": "UI starten",
|
||||
"detail": "vue-cli-service serve"
|
||||
}
|
||||
]
|
||||
}
|
24
ui/README.md
Normal file
24
ui/README.md
Normal file
|
@ -0,0 +1,24 @@
|
|||
# advent22_ui
|
||||
|
||||
## Project setup
|
||||
```
|
||||
yarn install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
yarn serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
yarn lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
5
ui/babel.config.json
Normal file
5
ui/babel.config.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"presets": [
|
||||
"@vue/cli-plugin-babel/preset"
|
||||
]
|
||||
}
|
47
ui/package.json
Normal file
47
ui/package.json
Normal file
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "advent22_ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"test:unit": "vue-cli-service test:unit",
|
||||
"test:unit-watch": "vue-cli-service test:unit --watch",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||
"@types/chai": "^4.3.14",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/mocha": "^10.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
||||
"@typescript-eslint/parser": "^7.3.1",
|
||||
"@vue/cli-plugin-babel": "~5.0.0",
|
||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||
"@vue/cli-plugin-typescript": "~5.0.0",
|
||||
"@vue/cli-plugin-unit-mocha": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vue/test-utils": "^2.4.5",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "^1.6.8",
|
||||
"bulma": "^0.9.4",
|
||||
"bulma-prefers-dark": "^0.1.0-beta.1",
|
||||
"bulma-toast": "2.4.3",
|
||||
"chai": "^4.3.10",
|
||||
"core-js": "^3.36.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.23.0",
|
||||
"luxon": "^3.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"sass": "^1.72.0",
|
||||
"sass-loader": "^14.1.1",
|
||||
"typescript": "~5.4.3",
|
||||
"vue": "^3.4.21",
|
||||
"vue-class-component": "^8.0.0-0"
|
||||
}
|
||||
}
|
BIN
ui/public/favicon.ico
Normal file
BIN
ui/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
32
ui/public/index.html
Normal file
32
ui/public/index.html
Normal file
|
@ -0,0 +1,32 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
<!-- Matomo -->
|
||||
<script>
|
||||
let _paq = window._paq = window._paq || [];
|
||||
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
|
||||
_paq.push(['trackPageView']);
|
||||
_paq.push(['enableLinkTracking']);
|
||||
(function () {
|
||||
const u = "https://stats.kiwi.lenaisten.de/";
|
||||
_paq.push(['setTrackerUrl', u + 'matomo.php']);
|
||||
_paq.push(['setSiteId', '10']);
|
||||
const d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
|
||||
g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s);
|
||||
})();
|
||||
</script>
|
||||
<!-- End Matomo Code -->
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>Es tut uns leid, aber <%= htmlWebpackPlugin.options.title %> funktioniert nicht richtig ohne JavaScript. Bitte aktivieren Sie es, um fortzufahren.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
67
ui/src/App.vue
Normal file
67
ui/src/App.vue
Normal file
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<section class="hero is-small is-primary">
|
||||
<div class="hero-body">
|
||||
<h1 class="title is-uppercase">{{ store.site_config.title }}</h1>
|
||||
<h2 class="subtitle">{{ store.site_config.subtitle }}</h2>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section px-3">
|
||||
<div class="container">
|
||||
<AdminView v-if="store.is_admin" />
|
||||
<UserView v-else />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="is-flex-grow-1" />
|
||||
|
||||
<footer class="footer">
|
||||
<div class="level">
|
||||
<div class="level-item">
|
||||
<p v-html="store.site_config.footer" />
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<TouchButton class="tag is-warning" />
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<AdminButton class="tag is-link is-outlined" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { advent22Store } from "./plugins/store";
|
||||
|
||||
import AdminView from "./components/admin/AdminView.vue";
|
||||
import AdminButton from "./components/AdminButton.vue";
|
||||
import TouchButton from "./components/TouchButton.vue";
|
||||
import UserView from "./components/UserView.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
AdminView,
|
||||
AdminButton,
|
||||
TouchButton,
|
||||
UserView,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
BIN
ui/src/assets/logo.png
(Stored with Git LFS)
Normal file
BIN
ui/src/assets/logo.png
(Stored with Git LFS)
Normal file
Binary file not shown.
22
ui/src/bulma-scheme.scss
Normal file
22
ui/src/bulma-scheme.scss
Normal file
|
@ -0,0 +1,22 @@
|
|||
@charset "utf-8";
|
||||
@use "sass:map";
|
||||
|
||||
//=====================
|
||||
// custom color scheme
|
||||
//=====================
|
||||
|
||||
$advent22-colors: (
|
||||
"primary": #945DE1,
|
||||
"link": #64B4BD,
|
||||
"info": #8C4E80,
|
||||
"success": #7E8E2B,
|
||||
"warning": #F6CA6B,
|
||||
"danger": #C5443B,
|
||||
);
|
||||
|
||||
$primary: map.get($advent22-colors, "primary");
|
||||
$link: map.get($advent22-colors, "link");
|
||||
$info: map.get($advent22-colors, "info");
|
||||
$success: map.get($advent22-colors, "success");
|
||||
$warning: map.get($advent22-colors, "warning");
|
||||
$danger: map.get($advent22-colors, "danger");
|
56
ui/src/components/AdminButton.vue
Normal file
56
ui/src/components/AdminButton.vue
Normal file
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<LoginModal v-if="modal_visible" @submit="on_submit" @cancel="on_cancel" />
|
||||
|
||||
<BulmaButton
|
||||
v-bind="$attrs"
|
||||
:icon="'fa-solid fa-toggle-' + (store.is_admin ? 'on' : 'off')"
|
||||
:busy="is_busy"
|
||||
text="Admin"
|
||||
@click.left="on_click"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Credentials } from "@/lib/api";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import BulmaButton from "./bulma/Button.vue";
|
||||
import LoginModal from "./LoginModal.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaButton,
|
||||
LoginModal,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public modal_visible = false;
|
||||
public is_busy = false;
|
||||
public readonly store = advent22Store();
|
||||
|
||||
public on_click() {
|
||||
if (this.store.is_admin) {
|
||||
this.store.logout();
|
||||
} else {
|
||||
// show login modal
|
||||
this.is_busy = true;
|
||||
this.modal_visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
public on_submit(creds: Credentials) {
|
||||
this.modal_visible = false;
|
||||
|
||||
this.store
|
||||
.login(creds)
|
||||
.catch(this.store.alert_user_error)
|
||||
.finally(() => (this.is_busy = false));
|
||||
}
|
||||
|
||||
public on_cancel() {
|
||||
this.modal_visible = false;
|
||||
this.is_busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
121
ui/src/components/Calendar.vue
Normal file
121
ui/src/components/Calendar.vue
Normal file
|
@ -0,0 +1,121 @@
|
|||
<template>
|
||||
<MultiModal @handle="modal_handle" />
|
||||
|
||||
<BulmaToast @handle="toast_handle" class="content">
|
||||
<p>
|
||||
Du hast noch keine Türchen geöffnet, vielleicht gibt es ein Anzeigeproblem
|
||||
in Deinem Webbrowser?
|
||||
</p>
|
||||
<div class="level">
|
||||
<div class="level-item">
|
||||
<BulmaButton
|
||||
class="is-success"
|
||||
text="Türchen anzeigen"
|
||||
@click.left="
|
||||
store.is_touch_device = true;
|
||||
toast?.hide();
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<BulmaButton
|
||||
class="is-danger"
|
||||
text="Ich möchte selbst suchen"
|
||||
@click.left="toast?.hide()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BulmaToast>
|
||||
|
||||
<figure>
|
||||
<div class="image is-unselectable">
|
||||
<img :src="store.calendar_background_image" />
|
||||
<ThouCanvas>
|
||||
<CalendarDoor
|
||||
v-for="(door, index) in doors"
|
||||
:key="`door-${index}`"
|
||||
:door="door"
|
||||
:visible="store.is_touch_device"
|
||||
:title="$advent22.name_door(door.day)"
|
||||
@click="door_click(door.day)"
|
||||
style="cursor: pointer"
|
||||
/>
|
||||
</ThouCanvas>
|
||||
</div>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Door } from "@/lib/door";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import MultiModal from "./MultiModal.vue";
|
||||
import BulmaButton from "./bulma/Button.vue";
|
||||
import BulmaToast from "./bulma/Toast.vue";
|
||||
import CalendarDoor from "./calendar/CalendarDoor.vue";
|
||||
import ThouCanvas from "./calendar/ThouCanvas.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
MultiModal,
|
||||
BulmaButton,
|
||||
BulmaToast,
|
||||
ThouCanvas,
|
||||
CalendarDoor,
|
||||
},
|
||||
props: {
|
||||
doors: Array,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly doors!: Door[];
|
||||
public readonly store = advent22Store();
|
||||
|
||||
private multi_modal?: MultiModal;
|
||||
|
||||
public toast?: BulmaToast;
|
||||
private toast_timeout?: number;
|
||||
|
||||
public modal_handle(modal: MultiModal) {
|
||||
this.multi_modal = modal;
|
||||
}
|
||||
|
||||
public toast_handle(toast: BulmaToast) {
|
||||
this.toast = toast;
|
||||
|
||||
if (this.store.is_touch_device) return;
|
||||
|
||||
this.store.when_initialized(() => {
|
||||
this.toast_timeout = setTimeout(() => {
|
||||
if (this.store.user_doors.length === 0) return;
|
||||
if (this.store.is_touch_device) return;
|
||||
|
||||
this.toast!.show({ duration: 600000, type: "is-warning" });
|
||||
}, 10e3);
|
||||
});
|
||||
}
|
||||
|
||||
public door_click(day: number) {
|
||||
if (this.toast_timeout !== undefined) clearTimeout(this.toast_timeout);
|
||||
this.toast?.hide();
|
||||
|
||||
if (this.multi_modal === undefined) return;
|
||||
this.multi_modal.show_progress();
|
||||
|
||||
this.$advent22
|
||||
.api_get_blob(`user/image_${day}`)
|
||||
.then((image_src) => {
|
||||
this.multi_modal!.show_image(image_src, this.$advent22.name_door(day));
|
||||
})
|
||||
.catch((error) => {
|
||||
this.store.alert_user_error(error);
|
||||
this.multi_modal!.hide();
|
||||
});
|
||||
}
|
||||
|
||||
public beforeUnmount(): void {
|
||||
this.toast?.hide();
|
||||
}
|
||||
}
|
||||
</script>
|
55
ui/src/components/CountDown.vue
Normal file
55
ui/src/components/CountDown.vue
Normal file
|
@ -0,0 +1,55 @@
|
|||
<template>
|
||||
{{ string_repr }}
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Duration } from "luxon";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
@Options({
|
||||
props: {
|
||||
until: Number,
|
||||
tick_time: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
private until!: number;
|
||||
private tick_time!: number;
|
||||
|
||||
private interval_id: number | null = null;
|
||||
public string_repr = "";
|
||||
|
||||
private tick(): void {
|
||||
const distance_ms = this.until - Date.now();
|
||||
|
||||
if (distance_ms <= 0) {
|
||||
this.string_repr = "Jetzt!";
|
||||
return;
|
||||
}
|
||||
|
||||
const distance = Duration.fromMillis(distance_ms);
|
||||
const d_days = distance.shiftTo("day").mapUnits(Math.floor);
|
||||
const d_hms = distance.minus(d_days).shiftTo("hour", "minute", "second");
|
||||
|
||||
if (d_days.days > 0) {
|
||||
this.string_repr = d_days.toHuman() + " ";
|
||||
} else {
|
||||
this.string_repr = "";
|
||||
}
|
||||
this.string_repr += d_hms.toFormat("hh:mm:ss");
|
||||
}
|
||||
|
||||
public mounted(): void {
|
||||
this.tick();
|
||||
this.interval_id = window.setInterval(this.tick, this.tick_time);
|
||||
}
|
||||
|
||||
public beforeUnmount(): void {
|
||||
if (this.interval_id === null) return;
|
||||
window.clearInterval(this.interval_id);
|
||||
}
|
||||
}
|
||||
</script>
|
94
ui/src/components/LoginModal.vue
Normal file
94
ui/src/components/LoginModal.vue
Normal file
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<div class="modal is-active">
|
||||
<div class="modal-background" />
|
||||
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Login</p>
|
||||
<button class="delete" @click.left="cancel" />
|
||||
</header>
|
||||
|
||||
<section class="modal-card-body">
|
||||
<div class="field">
|
||||
<label class="label">Username</label>
|
||||
<div class="control">
|
||||
<input
|
||||
ref="username_input"
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Passwort</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" v-model="password" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot is-flex is-justify-content-space-around">
|
||||
<BulmaButton
|
||||
class="is-success"
|
||||
@click.left="submit"
|
||||
icon="fa-solid fa-unlock"
|
||||
text="Login"
|
||||
/>
|
||||
<BulmaButton
|
||||
class="is-danger"
|
||||
@click.left="cancel"
|
||||
icon="fa-solid fa-circle-xmark"
|
||||
text="Abbrechen"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import BulmaButton from "./bulma/Button.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaButton,
|
||||
},
|
||||
props: {
|
||||
visible: Boolean,
|
||||
},
|
||||
emits: ["cancel", "submit"],
|
||||
})
|
||||
export default class extends Vue {
|
||||
public username = "";
|
||||
public password = "";
|
||||
|
||||
private on_keydown(e: KeyboardEvent) {
|
||||
if (e.key == "Enter") this.submit();
|
||||
else if (e.key == "Escape") this.cancel();
|
||||
}
|
||||
|
||||
public mounted(): void {
|
||||
window.addEventListener("keydown", this.on_keydown);
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (!(this.$refs.username_input instanceof HTMLElement)) return;
|
||||
this.$refs.username_input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
public beforeUnmount(): void {
|
||||
window.removeEventListener("keydown", this.on_keydown);
|
||||
}
|
||||
|
||||
public submit(): void {
|
||||
this.$emit("submit", [this.username, this.password]);
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this.$emit("cancel");
|
||||
}
|
||||
}
|
||||
</script>
|
83
ui/src/components/MultiModal.vue
Normal file
83
ui/src/components/MultiModal.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="modal is-active" v-if="active" @click="dismiss()">
|
||||
<div class="modal-background" />
|
||||
|
||||
<div class="modal-content" style="max-height: 100vh; max-width: 95vw">
|
||||
<template v-if="progress">
|
||||
<progress class="progress is-primary" max="100" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<figure>
|
||||
<figcaption class="tag is-primary">
|
||||
{{ caption }}
|
||||
</figcaption>
|
||||
<div class="image is-square">
|
||||
<img :src="image_src" alt="Kalender-Bild" />
|
||||
</div>
|
||||
</figure>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!progress"
|
||||
class="modal-close is-large has-background-primary"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
@Options({
|
||||
emits: ["handle"],
|
||||
})
|
||||
export default class extends Vue {
|
||||
public active = false;
|
||||
public progress = false;
|
||||
public image_src = "";
|
||||
public caption = "";
|
||||
|
||||
private on_keydown(e: KeyboardEvent) {
|
||||
if (e.key == "Escape") this.dismiss();
|
||||
}
|
||||
|
||||
public created(): void {
|
||||
this.$emit("handle", this);
|
||||
}
|
||||
|
||||
public mounted(): void {
|
||||
window.addEventListener("keydown", this.on_keydown);
|
||||
}
|
||||
|
||||
public beforeUnmount(): void {
|
||||
window.removeEventListener("keydown", this.on_keydown);
|
||||
}
|
||||
|
||||
public show() {
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
public hide() {
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
public dismiss() {
|
||||
// Cannot dismiss the "loading" screen
|
||||
if (this.active && this.progress) return;
|
||||
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
public show_image(src: string, caption: string = "") {
|
||||
this.progress = false;
|
||||
this.image_src = src;
|
||||
this.caption = caption;
|
||||
this.show();
|
||||
}
|
||||
|
||||
public show_progress() {
|
||||
this.progress = true;
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
</script>
|
28
ui/src/components/TouchButton.vue
Normal file
28
ui/src/components/TouchButton.vue
Normal file
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<span>Eingabemodus: </span>
|
||||
<BulmaButton
|
||||
v-bind="$attrs"
|
||||
:icon="
|
||||
'fa-solid fa-' +
|
||||
(store.is_touch_device ? 'hand-pointer' : 'arrow-pointer')
|
||||
"
|
||||
:text="store.is_touch_device ? 'Touch' : 'Desktop'"
|
||||
@click.left="store.toggle_touch_device"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import BulmaButton from "./bulma/Button.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaButton,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
}
|
||||
</script>
|
41
ui/src/components/UserView.vue
Normal file
41
ui/src/components/UserView.vue
Normal file
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<template v-if="store.is_initialized === true">
|
||||
<Calendar :doors="store.user_doors" />
|
||||
<hr />
|
||||
<div class="content" v-html="store.site_config.content" />
|
||||
<div class="content has-text-primary">
|
||||
<template v-if="store.next_door_target === null">
|
||||
Alle {{ store.user_doors.length }} Türchen offen!
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="store.user_doors.length === 0">
|
||||
Zeit bis zum ersten Türchen:
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ store.user_doors.length }} Türchen offen. Zeit bis zum nächsten
|
||||
Türchen:
|
||||
</template>
|
||||
<CountDown :until="store.next_door_target" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<progress v-else class="progress is-primary" max="100" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import Calendar from "./Calendar.vue";
|
||||
import CountDown from "./CountDown.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
Calendar,
|
||||
CountDown,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
}
|
||||
</script>
|
22
ui/src/components/admin/AdminView.vue
Normal file
22
ui/src/components/admin/AdminView.vue
Normal file
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<ConfigView />
|
||||
<CalendarAssistant />
|
||||
<DoorMapEditor />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import CalendarAssistant from "./CalendarAssistant.vue";
|
||||
import ConfigView from "./ConfigView.vue";
|
||||
import DoorMapEditor from "./DoorMapEditor.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
ConfigView,
|
||||
CalendarAssistant,
|
||||
DoorMapEditor,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {}
|
||||
</script>
|
116
ui/src/components/admin/CalendarAssistant.vue
Normal file
116
ui/src/components/admin/CalendarAssistant.vue
Normal file
|
@ -0,0 +1,116 @@
|
|||
<template>
|
||||
<MultiModal @handle="modal_handle" />
|
||||
|
||||
<BulmaDrawer header="Kalender-Assistent" @open="on_open" refreshable>
|
||||
<div class="card-content">
|
||||
<div class="content">
|
||||
<p>Hervorgehobenen Tagen wurde kein Buchstabe zugewiesen.</p>
|
||||
|
||||
<h3>Zuordnung Buchstaben</h3>
|
||||
<div class="tags are-medium">
|
||||
<template v-for="(data, day) in day_data" :key="`part-${day}`">
|
||||
<span v-if="data.part === ''" class="tag is-warning">
|
||||
{{ day }}
|
||||
</span>
|
||||
<span v-else class="tag is-info">
|
||||
{{ day }}: {{ data.part.split("").join(", ") }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<h3>Zuordnung Bilder</h3>
|
||||
<div class="tags are-medium">
|
||||
<span
|
||||
v-for="(data, day) in day_data"
|
||||
:key="`image-${day}`"
|
||||
:class="'tag is-' + (data.part === '' ? 'warning' : 'primary')"
|
||||
>
|
||||
{{ day }}: {{ data.image_name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3>Alle Türchen</h3>
|
||||
<div class="tags are-medium">
|
||||
<BulmaButton
|
||||
v-for="(data, day) in day_data"
|
||||
:key="`btn-${day}`"
|
||||
:class="'tag is-' + (data.part === '' ? 'warning' : 'info')"
|
||||
icon="fa-solid fa-door-open"
|
||||
:text="day"
|
||||
@click.left="door_click(day)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BulmaDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { NumStrDict, objForEach } from "@/lib/api";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import MultiModal from "../MultiModal.vue";
|
||||
import BulmaButton from "../bulma/Button.vue";
|
||||
import BulmaDrawer from "../bulma/Drawer.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaButton,
|
||||
BulmaDrawer,
|
||||
MultiModal,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public day_data: {
|
||||
[day: number]: {
|
||||
part: string;
|
||||
image_name: string;
|
||||
};
|
||||
} = {};
|
||||
|
||||
private multi_modal?: MultiModal;
|
||||
|
||||
public modal_handle(modal: MultiModal) {
|
||||
this.multi_modal = modal;
|
||||
}
|
||||
|
||||
public on_open(ready: () => void, fail: () => void): void {
|
||||
Promise.all([
|
||||
this.$advent22.api_get<NumStrDict>("admin/day_parts"),
|
||||
this.$advent22.api_get<NumStrDict>("admin/day_image_names"),
|
||||
])
|
||||
.then(([day_parts, day_image_names]) => {
|
||||
const _ensure_day_in_data = (day: number) => {
|
||||
if (!(day in this.day_data)) {
|
||||
this.day_data[day] = { part: "", image_name: "" };
|
||||
}
|
||||
};
|
||||
|
||||
objForEach(day_parts, (day, part) => {
|
||||
_ensure_day_in_data(day);
|
||||
this.day_data[day].part = part;
|
||||
});
|
||||
|
||||
objForEach(day_image_names, (day, image_name) => {
|
||||
_ensure_day_in_data(day);
|
||||
this.day_data[day].image_name = image_name;
|
||||
});
|
||||
|
||||
ready();
|
||||
})
|
||||
.catch(fail);
|
||||
}
|
||||
|
||||
public door_click(day: number) {
|
||||
if (this.multi_modal === undefined) return;
|
||||
this.multi_modal.show_progress();
|
||||
|
||||
this.$advent22
|
||||
.api_get_blob(`user/image_${day}`)
|
||||
.then((image_src) =>
|
||||
this.multi_modal!.show_image(image_src, this.$advent22.name_door(day)),
|
||||
)
|
||||
.catch(() => this.multi_modal!.hide());
|
||||
}
|
||||
}
|
||||
</script>
|
293
ui/src/components/admin/ConfigView.vue
Normal file
293
ui/src/components/admin/ConfigView.vue
Normal file
|
@ -0,0 +1,293 @@
|
|||
<template>
|
||||
<BulmaDrawer header="Konfiguration" @open="on_open" refreshable>
|
||||
<div class="card-content">
|
||||
<div class="columns">
|
||||
<div class="column is-one-third">
|
||||
<div class="content">
|
||||
<h3>Lösung</h3>
|
||||
<dl>
|
||||
<dt>Wert</dt>
|
||||
<dd>
|
||||
Eingabe:
|
||||
<span class="is-family-monospace">
|
||||
"{{ admin_config_model.solution.value }}"
|
||||
</span>
|
||||
</dd>
|
||||
<dd>
|
||||
Ausgabe:
|
||||
<span class="is-family-monospace">
|
||||
"{{ admin_config_model.solution.clean }}"
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt>Transformation</dt>
|
||||
<dd>
|
||||
Whitespace:
|
||||
<span class="is-uppercase is-family-monospace">
|
||||
{{ admin_config_model.solution.whitespace }}
|
||||
</span>
|
||||
</dd>
|
||||
<dd>
|
||||
Sonderzeichen:
|
||||
<span class="is-uppercase is-family-monospace">
|
||||
{{ admin_config_model.solution.special_chars }}
|
||||
</span>
|
||||
</dd>
|
||||
<dd>
|
||||
Buchstaben:
|
||||
<span class="is-uppercase is-family-monospace">
|
||||
{{ admin_config_model.solution.case }}
|
||||
</span>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Rätsel</h3>
|
||||
<dl>
|
||||
<dt>Offene Türchen</dt>
|
||||
<dd>{{ store.user_doors.length }}</dd>
|
||||
|
||||
<dt>Zeit zum nächsten Türchen</dt>
|
||||
<dd v-if="store.next_door_target === null">
|
||||
Kein nächstes Türchen
|
||||
</dd>
|
||||
<dd v-else><CountDown :until="store.next_door_target" /></dd>
|
||||
|
||||
<dt>Erstes Türchen</dt>
|
||||
<dd>{{ fmt_puzzle_date("first") }}</dd>
|
||||
|
||||
<dt>Nächstes Türchen</dt>
|
||||
<dd>{{ fmt_puzzle_date("next") }}</dd>
|
||||
|
||||
<dt>Letztes Türchen</dt>
|
||||
<dd>{{ fmt_puzzle_date("last") }}</dd>
|
||||
|
||||
<dt>Rätsel schließt nach</dt>
|
||||
<dd>{{ fmt_puzzle_date("end") }}</dd>
|
||||
|
||||
<dt>Zufalls-Seed</dt>
|
||||
<dd class="is-family-monospace">
|
||||
"{{ admin_config_model.puzzle.seed }}"
|
||||
</dd>
|
||||
|
||||
<dt>Extra-Tage</dt>
|
||||
<dd>
|
||||
<template
|
||||
v-for="(day, index) in admin_config_model.puzzle.extra_days"
|
||||
:key="`extra_day-${index}`"
|
||||
>
|
||||
<span>
|
||||
<template v-if="index > 0">, </template>
|
||||
{{ day }}
|
||||
</span>
|
||||
</template>
|
||||
</dd>
|
||||
|
||||
<dt>Leere Türchen</dt>
|
||||
<dd v-if="admin_config_model.puzzle.skip_empty">Überspringen</dd>
|
||||
<dd v-else>Anzeigen</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-one-third">
|
||||
<div class="content">
|
||||
<h3>Kalender</h3>
|
||||
<dl>
|
||||
<dt>Definition</dt>
|
||||
<dd>{{ admin_config_model.calendar.config_file }}</dd>
|
||||
|
||||
<dt>Hintergrundbild</dt>
|
||||
<dd>{{ admin_config_model.calendar.background }}</dd>
|
||||
|
||||
<dt>Favicon</dt>
|
||||
<dd>{{ admin_config_model.calendar.favicon }}</dd>
|
||||
|
||||
<dt>Türchen ({{ doors.length }} Stück)</dt>
|
||||
<dd>
|
||||
<template v-for="(door, index) in doors" :key="`door-${index}`">
|
||||
<span>
|
||||
<template v-if="index > 0">, </template>
|
||||
{{ door.day }}
|
||||
</span>
|
||||
</template>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Bilder</h3>
|
||||
<dl>
|
||||
<dt>Größe</dt>
|
||||
<dd>{{ admin_config_model.image.size }} px</dd>
|
||||
|
||||
<dt>Rand</dt>
|
||||
<dd>{{ admin_config_model.image.border }} px</dd>
|
||||
|
||||
<dt>Schriftarten</dt>
|
||||
<dd
|
||||
v-for="(font, index) in admin_config_model.fonts"
|
||||
:key="`font-${index}`"
|
||||
>
|
||||
{{ font.file }} ({{ font.size }} pt)
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-one-third">
|
||||
<div class="content">
|
||||
<h3>WebDAV</h3>
|
||||
<dl>
|
||||
<dt>URL</dt>
|
||||
<dd>{{ admin_config_model.webdav.url }}</dd>
|
||||
|
||||
<dt>Zugangsdaten</dt>
|
||||
<dd class="is-family-monospace">
|
||||
<BulmaSecret @load="load_dav_credentials">
|
||||
<span class="tag is-danger">user</span>
|
||||
{{ dav_credentials[0] }}
|
||||
<br />
|
||||
<span class="tag is-danger">pass</span>
|
||||
{{ dav_credentials[1] }}
|
||||
</BulmaSecret>
|
||||
</dd>
|
||||
|
||||
<dt>Cache-Dauer</dt>
|
||||
<dd>{{ admin_config_model.webdav.cache_ttl }} s</dd>
|
||||
|
||||
<dt>Konfigurationsdatei</dt>
|
||||
<dd>{{ admin_config_model.webdav.config_file }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h3>Sonstige</h3>
|
||||
<dl>
|
||||
<dt>Redis</dt>
|
||||
<dd>Host: {{ admin_config_model.redis.host }}</dd>
|
||||
<dd>Port: {{ admin_config_model.redis.port }}</dd>
|
||||
<dd>Datenbank: {{ admin_config_model.redis.db }}</dd>
|
||||
<dd>Protokoll: {{ admin_config_model.redis.protocol }}</dd>
|
||||
|
||||
<dt>UI-Admin</dt>
|
||||
<dd class="is-family-monospace">
|
||||
<BulmaSecret @load="load_ui_credentials">
|
||||
<span class="tag is-danger">user</span>
|
||||
{{ ui_credentials[0] }}
|
||||
<br />
|
||||
<span class="tag is-danger">pass</span>
|
||||
{{ ui_credentials[1] }}
|
||||
</BulmaSecret>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BulmaDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { AdminConfigModel, Credentials, DoorSaved } from "@/lib/api";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { DateTime } from "luxon";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import BulmaDrawer from "../bulma/Drawer.vue";
|
||||
import BulmaSecret from "../bulma/Secret.vue";
|
||||
import CountDown from "../CountDown.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaDrawer,
|
||||
BulmaSecret,
|
||||
CountDown,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
|
||||
public admin_config_model: AdminConfigModel = {
|
||||
solution: {
|
||||
value: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
whitespace: "KEEP",
|
||||
special_chars: "KEEP",
|
||||
case: "KEEP",
|
||||
clean: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
},
|
||||
puzzle: {
|
||||
first: "2023-12-01",
|
||||
next: "2023-12-01",
|
||||
last: "2023-12-24",
|
||||
end: "2024-04-01",
|
||||
seed: "",
|
||||
extra_days: [],
|
||||
skip_empty: true,
|
||||
},
|
||||
calendar: {
|
||||
config_file: "lorem ipsum",
|
||||
background: "dolor sit",
|
||||
favicon: "sit amet",
|
||||
},
|
||||
image: {
|
||||
size: 500,
|
||||
border: 0,
|
||||
},
|
||||
fonts: [{ file: "consetetur", size: 0 }],
|
||||
redis: {
|
||||
host: "0.0.0.0",
|
||||
port: 6379,
|
||||
db: 0,
|
||||
protocol: 3,
|
||||
},
|
||||
webdav: {
|
||||
url: "sadipscing elitr",
|
||||
cache_ttl: 0,
|
||||
config_file: "sed diam nonumy",
|
||||
},
|
||||
};
|
||||
public doors: DoorSaved[] = [];
|
||||
public dav_credentials: Credentials = ["", ""];
|
||||
public ui_credentials: Credentials = ["", ""];
|
||||
|
||||
public fmt_puzzle_date(name: keyof AdminConfigModel["puzzle"]): string {
|
||||
const iso_date = this.admin_config_model.puzzle[name];
|
||||
if (!(typeof iso_date == "string")) return "-";
|
||||
|
||||
return DateTime.fromISO(iso_date).toLocaleString(DateTime.DATE_SHORT);
|
||||
}
|
||||
|
||||
public on_open(ready: () => void, fail: () => void): void {
|
||||
Promise.all([
|
||||
this.store.update(),
|
||||
this.$advent22.api_get<AdminConfigModel>("admin/config_model"),
|
||||
this.$advent22.api_get<DoorSaved[]>("admin/doors"),
|
||||
])
|
||||
.then(([store_update, admin_config_model, doors]) => {
|
||||
store_update; // discard value
|
||||
|
||||
this.admin_config_model = admin_config_model;
|
||||
this.doors = doors;
|
||||
|
||||
ready();
|
||||
})
|
||||
.catch(fail);
|
||||
}
|
||||
|
||||
public load_dav_credentials(): void {
|
||||
this.$advent22
|
||||
.api_get<Credentials>("admin/dav_credentials")
|
||||
.then((creds) => (this.dav_credentials = creds))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
public load_ui_credentials(): void {
|
||||
this.$advent22
|
||||
.api_get<Credentials>("admin/ui_credentials")
|
||||
.then((creds) => (this.ui_credentials = creds))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
dd {
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
195
ui/src/components/admin/DoorMapEditor.vue
Normal file
195
ui/src/components/admin/DoorMapEditor.vue
Normal file
|
@ -0,0 +1,195 @@
|
|||
<template>
|
||||
<BulmaDrawer header="Türchen bearbeiten" @open="on_open">
|
||||
<nav class="level is-mobile mb-0" style="overflow-x: auto">
|
||||
<BulmaButton
|
||||
:disabled="current_step === 0"
|
||||
class="level-item is-link"
|
||||
@click="current_step--"
|
||||
icon="fa-solid fa-backward"
|
||||
/>
|
||||
|
||||
<BulmaBreadcrumbs
|
||||
:steps="steps"
|
||||
v-model="current_step"
|
||||
class="level-item mb-0"
|
||||
/>
|
||||
|
||||
<BulmaButton
|
||||
:disabled="current_step === 2"
|
||||
class="level-item is-link"
|
||||
@click="current_step++"
|
||||
icon="fa-solid fa-forward"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="card-content pb-0">
|
||||
<div v-if="doors.length > 0" class="content">
|
||||
<p>Für diese Tage ist ein Türchen vorhanden:</p>
|
||||
<div class="tags">
|
||||
<span
|
||||
v-for="(door, index) in doors.toSorted((a, b) => a.day - b.day)"
|
||||
:key="`door-${index}`"
|
||||
class="tag is-primary"
|
||||
>
|
||||
{{ door.day }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DoorPlacer v-if="current_step === 0" :doors="doors" />
|
||||
<DoorChooser v-if="current_step === 1" :doors="doors" />
|
||||
<div v-if="current_step === 2" class="card-content">
|
||||
<Calendar :doors="doors" />
|
||||
</div>
|
||||
|
||||
<footer class="card-footer is-flex is-justify-content-space-around">
|
||||
<BulmaButton
|
||||
class="card-footer-item is-danger"
|
||||
@click="on_download"
|
||||
icon="fa-solid fa-cloud-arrow-down"
|
||||
:busy="loading_doors"
|
||||
text="Laden"
|
||||
/>
|
||||
<BulmaButton
|
||||
class="card-footer-item is-warning"
|
||||
@click="on_discard"
|
||||
icon="fa-solid fa-trash"
|
||||
text="Löschen"
|
||||
/>
|
||||
<BulmaButton
|
||||
class="card-footer-item is-success"
|
||||
@click="on_upload"
|
||||
icon="fa-solid fa-cloud-arrow-up"
|
||||
:busy="saving_doors"
|
||||
text="Speichern"
|
||||
/>
|
||||
</footer>
|
||||
</BulmaDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { DoorSaved } from "@/lib/api";
|
||||
import { Door } from "@/lib/door";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import { toast } from "bulma-toast";
|
||||
import Calendar from "../Calendar.vue";
|
||||
import BulmaBreadcrumbs, { Step } from "../bulma/Breadcrumbs.vue";
|
||||
import BulmaButton from "../bulma/Button.vue";
|
||||
import BulmaDrawer from "../bulma/Drawer.vue";
|
||||
import DoorChooser from "../editor/DoorChooser.vue";
|
||||
import DoorPlacer from "../editor/DoorPlacer.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaBreadcrumbs,
|
||||
BulmaButton,
|
||||
BulmaDrawer,
|
||||
DoorPlacer,
|
||||
DoorChooser,
|
||||
Calendar,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly steps: Step[] = [
|
||||
{ label: "Platzieren", icon: "fa-solid fa-crosshairs" },
|
||||
{ label: "Ordnen", icon: "fa-solid fa-list-ol" },
|
||||
{ label: "Vorschau", icon: "fa-solid fa-magnifying-glass" },
|
||||
];
|
||||
public current_step = 0;
|
||||
public doors: Door[] = [];
|
||||
private readonly store = advent22Store();
|
||||
|
||||
public loading_doors = false;
|
||||
public saving_doors = false;
|
||||
|
||||
private load_doors(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.$advent22
|
||||
.api_get<DoorSaved[]>("admin/doors")
|
||||
.then((data) => {
|
||||
this.doors.length = 0;
|
||||
|
||||
for (const value of data) {
|
||||
this.doors.push(Door.load(value));
|
||||
}
|
||||
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
this.store.alert_user_error(error);
|
||||
reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private save_doors(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const data: DoorSaved[] = [];
|
||||
|
||||
for (const door of this.doors) {
|
||||
data.push(door.save());
|
||||
}
|
||||
|
||||
this.$advent22
|
||||
.api_put("admin/doors", data)
|
||||
.then(resolve)
|
||||
.catch((error) => {
|
||||
this.store.alert_user_error(error);
|
||||
reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public on_open(ready: () => void, fail: () => void): void {
|
||||
this.load_doors().then(ready).catch(fail);
|
||||
}
|
||||
|
||||
public on_download() {
|
||||
if (confirm("Aktuelle Änderungen verwerfen und Status vom Server laden?")) {
|
||||
this.loading_doors = true;
|
||||
|
||||
this.load_doors()
|
||||
.then(() =>
|
||||
toast({
|
||||
message: "Erfolgreich!",
|
||||
type: "is-success",
|
||||
duration: 2e3,
|
||||
}),
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => (this.loading_doors = false));
|
||||
}
|
||||
}
|
||||
|
||||
public on_discard() {
|
||||
if (confirm("Alle Türchen löschen? (nur lokal)")) {
|
||||
// empty `doors` array
|
||||
this.doors.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public on_upload() {
|
||||
if (confirm("Aktuelle Änderungen an den Server schicken?")) {
|
||||
this.saving_doors = true;
|
||||
|
||||
this.save_doors()
|
||||
.then(() => {
|
||||
this.load_doors()
|
||||
.then(() =>
|
||||
toast({
|
||||
message: "Erfolgreich!",
|
||||
type: "is-success",
|
||||
duration: 2e3,
|
||||
}),
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => (this.saving_doors = false));
|
||||
})
|
||||
.catch(() => (this.saving_doors = false));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
48
ui/src/components/bulma/Breadcrumbs.vue
Normal file
48
ui/src/components/bulma/Breadcrumbs.vue
Normal file
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<nav class="breadcrumb has-succeeds-separator">
|
||||
<ul>
|
||||
<li
|
||||
v-for="(step, index) in steps"
|
||||
:key="`step-${index}`"
|
||||
:class="modelValue === index ? 'is-active' : ''"
|
||||
@click.left="change_step(index)"
|
||||
>
|
||||
<a>
|
||||
<span class="icon is-small">
|
||||
<font-awesome-icon :icon="step.icon" />
|
||||
</span>
|
||||
<span>{{ step.label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
export interface Step {
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@Options({
|
||||
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) {
|
||||
if (next_step === this.modelValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("update:modelValue", next_step);
|
||||
}
|
||||
}
|
||||
</script>
|
45
ui/src/components/bulma/Button.vue
Normal file
45
ui/src/components/bulma/Button.vue
Normal file
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<button class="button">
|
||||
<slot v-if="text === undefined" name="default">
|
||||
<font-awesome-icon
|
||||
v-if="icon !== undefined"
|
||||
:icon="icon"
|
||||
:beat-fade="busy"
|
||||
/>
|
||||
</slot>
|
||||
<template v-else>
|
||||
<span v-if="icon !== undefined" class="icon">
|
||||
<slot name="default">
|
||||
<font-awesome-icon :icon="icon" :beat-fade="busy" />
|
||||
</slot>
|
||||
</span>
|
||||
<span>{{ text }}</span>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
@Options({
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
busy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public icon?: string;
|
||||
public text?: string;
|
||||
public busy!: boolean;
|
||||
}
|
||||
</script>
|
105
ui/src/components/bulma/Drawer.vue
Normal file
105
ui/src/components/bulma/Drawer.vue
Normal file
|
@ -0,0 +1,105 @@
|
|||
<template>
|
||||
<div class="card">
|
||||
<header class="card-header is-unselectable" style="cursor: pointer">
|
||||
<p class="card-header-title" @click="toggle">{{ header }}</p>
|
||||
|
||||
<p v-if="refreshable" class="card-header-icon px-0">
|
||||
<BulmaButton class="tag icon is-primary" @click="refresh">
|
||||
<font-awesome-icon
|
||||
icon="fa-solid fa-arrows-rotate"
|
||||
:spin="is_open && loading"
|
||||
/>
|
||||
</BulmaButton>
|
||||
</p>
|
||||
|
||||
<button class="card-header-icon" @click="toggle">
|
||||
<span class="icon">
|
||||
<font-awesome-icon
|
||||
:icon="'fa-solid fa-angle-' + (is_open ? 'down' : 'right')"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<template v-if="is_open">
|
||||
<div v-if="loading" class="card-content">
|
||||
<progress class="progress is-primary" max="100" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="failed"
|
||||
class="card-content has-text-danger has-text-centered"
|
||||
>
|
||||
<span class="icon is-large">
|
||||
<font-awesome-icon icon="fa-solid fa-ban" size="3x" />
|
||||
</span>
|
||||
</div>
|
||||
<slot v-else name="default" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import BulmaButton from "./Button.vue";
|
||||
|
||||
enum DrawerState {
|
||||
Loading,
|
||||
Ready,
|
||||
Failed,
|
||||
}
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
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;
|
||||
public state = DrawerState.Loading;
|
||||
|
||||
public toggle() {
|
||||
this.is_open = !this.is_open;
|
||||
|
||||
if (this.is_open) {
|
||||
this.state = DrawerState.Loading;
|
||||
|
||||
this.$emit(
|
||||
"open",
|
||||
() => (this.state = DrawerState.Ready),
|
||||
() => (this.state = DrawerState.Failed),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
this.is_open = false;
|
||||
this.toggle();
|
||||
}
|
||||
|
||||
public get loading(): boolean {
|
||||
return this.state === DrawerState.Loading;
|
||||
}
|
||||
|
||||
public get failed(): boolean {
|
||||
return this.state === DrawerState.Failed;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
div.card:not(:last-child) {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
68
ui/src/components/bulma/Secret.vue
Normal file
68
ui/src/components/bulma/Secret.vue
Normal file
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<slot v-if="show" name="default" />
|
||||
<span v-else>***</span>
|
||||
<BulmaButton
|
||||
:class="`tag icon is-${button_class} ml-2`"
|
||||
:icon="`fa-solid fa-${button_icon}`"
|
||||
:busy="busy"
|
||||
@click="on_click"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import BulmaButton from "./Button.vue";
|
||||
|
||||
enum ClickState {
|
||||
Green = 0,
|
||||
Yellow = 1,
|
||||
Red = 2,
|
||||
}
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
BulmaButton,
|
||||
},
|
||||
emits: ["load"],
|
||||
})
|
||||
export default class extends Vue {
|
||||
public state = ClickState.Green;
|
||||
|
||||
public on_click(): void {
|
||||
this.state++;
|
||||
this.state %= 3;
|
||||
|
||||
if (this.state === ClickState.Red) {
|
||||
this.$emit("load");
|
||||
}
|
||||
}
|
||||
|
||||
public get show(): boolean {
|
||||
return this.state === ClickState.Red;
|
||||
}
|
||||
|
||||
public get busy(): boolean {
|
||||
return this.state === ClickState.Yellow;
|
||||
}
|
||||
|
||||
public get button_class(): string {
|
||||
switch (this.state) {
|
||||
case ClickState.Red:
|
||||
return "danger";
|
||||
case ClickState.Yellow:
|
||||
return "warning";
|
||||
default:
|
||||
return "primary";
|
||||
}
|
||||
}
|
||||
|
||||
public get button_icon(): string {
|
||||
if (this.state === ClickState.Red) {
|
||||
return "eye-slash";
|
||||
} else {
|
||||
return "eye";
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
43
ui/src/components/bulma/Toast.vue
Normal file
43
ui/src/components/bulma/Toast.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<div style="display: none">
|
||||
<div v-bind="$attrs" ref="message">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import * as bulmaToast from "bulma-toast";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
@Options({
|
||||
emits: ["handle"],
|
||||
})
|
||||
export default class extends Vue {
|
||||
public created(): void {
|
||||
this.$emit("handle", this);
|
||||
}
|
||||
|
||||
public show(options: bulmaToast.Options = {}) {
|
||||
if (!(this.$refs.message instanceof HTMLElement)) return;
|
||||
|
||||
bulmaToast.toast({
|
||||
...options,
|
||||
single: true,
|
||||
message: this.$refs.message,
|
||||
});
|
||||
}
|
||||
|
||||
public hide() {
|
||||
if (!(this.$refs.message instanceof HTMLElement)) return;
|
||||
|
||||
const toast_div = this.$refs.message.parentElement;
|
||||
if (!(toast_div instanceof HTMLDivElement)) return;
|
||||
|
||||
const dbutton = toast_div.querySelector("button.delete");
|
||||
if (!(dbutton instanceof HTMLButtonElement)) return;
|
||||
|
||||
dbutton.click();
|
||||
}
|
||||
}
|
||||
</script>
|
41
ui/src/components/calendar/CalendarDoor.vue
Normal file
41
ui/src/components/calendar/CalendarDoor.vue
Normal file
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<SVGRect
|
||||
variant="primary"
|
||||
:visible="store.is_touch_device || force_visible"
|
||||
:rectangle="door.position"
|
||||
>
|
||||
<div
|
||||
class="has-text-danger"
|
||||
style="text-shadow: 0 0 10px white, 0 0 20px white"
|
||||
>
|
||||
{{ door.day }}
|
||||
</div>
|
||||
</SVGRect>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Door } from "@/lib/door";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import SVGRect from "./SVGRect.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
SVGRect,
|
||||
},
|
||||
props: {
|
||||
door: Door,
|
||||
force_visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
|
||||
public door!: Door;
|
||||
public force_visible!: boolean;
|
||||
}
|
||||
</script>
|
86
ui/src/components/calendar/SVGRect.vue
Normal file
86
ui/src/components/calendar/SVGRect.vue
Normal file
|
@ -0,0 +1,86 @@
|
|||
<template>
|
||||
<foreignObject
|
||||
:x="Math.round(store.calendar_aspect_ratio * rectangle.left)"
|
||||
:y="rectangle.top"
|
||||
:width="Math.round(store.calendar_aspect_ratio * rectangle.width)"
|
||||
:height="rectangle.height"
|
||||
:style="`transform: scaleX(${1 / store.calendar_aspect_ratio})`"
|
||||
>
|
||||
<div
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
:class="`px-2 is-flex is-align-items-center is-justify-content-center is-size-2 has-text-weight-bold ${extra_classes}`"
|
||||
style="height: inherit"
|
||||
:title="title"
|
||||
>
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</foreignObject>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Rectangle } from "@/lib/rectangle";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
type BulmaVariant =
|
||||
| "primary"
|
||||
| "link"
|
||||
| "info"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
|
||||
@Options({
|
||||
props: {
|
||||
variant: String,
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rectangle: Rectangle,
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
|
||||
private variant!: BulmaVariant;
|
||||
private visible!: boolean;
|
||||
public rectangle!: Rectangle;
|
||||
public title?: string;
|
||||
|
||||
public get extra_classes(): string {
|
||||
let result = this.variant;
|
||||
|
||||
if (this.visible) result += " visible";
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/bulma-scheme";
|
||||
|
||||
foreignObject > div {
|
||||
&:not(.visible, :hover):deep() > * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.visible,
|
||||
&:hover {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
|
||||
@each $name, $color in $advent22-colors {
|
||||
&.#{$name} {
|
||||
background-color: rgba($color, 0.3);
|
||||
border-color: rgba($color, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
87
ui/src/components/calendar/ThouCanvas.vue
Normal file
87
ui/src/components/calendar/ThouCanvas.vue
Normal file
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
version="1.1"
|
||||
viewBox="0 0 1000 1000"
|
||||
preserveAspectRatio="none"
|
||||
@contextmenu.prevent
|
||||
@mousedown="transform_mouse_event"
|
||||
@mousemove="transform_mouse_event"
|
||||
@mouseup="transform_mouse_event"
|
||||
@click="transform_mouse_event"
|
||||
@dblclick="transform_mouse_event"
|
||||
>
|
||||
<slot name="default" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vector2D } from "@/lib/vector2d";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
function get_event_thous(event: MouseEvent): Vector2D {
|
||||
if (!(event.currentTarget instanceof SVGSVGElement)) {
|
||||
return new Vector2D();
|
||||
}
|
||||
|
||||
return new Vector2D(
|
||||
Math.round((event.offsetX / event.currentTarget.clientWidth) * 1000),
|
||||
Math.round((event.offsetY / event.currentTarget.clientHeight) * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
function mouse_event_validator(event: object, point: object): boolean {
|
||||
if (!(event instanceof MouseEvent)) {
|
||||
console.warn(event, "is not a MouseEvent!");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(point instanceof Vector2D)) {
|
||||
console.warn(point, "is not a Vector2D!");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Options({
|
||||
emits: {
|
||||
mousedown: mouse_event_validator,
|
||||
mouseup: mouse_event_validator,
|
||||
mousemove: mouse_event_validator,
|
||||
click: mouse_event_validator,
|
||||
dblclick: mouse_event_validator,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public readonly store = advent22Store();
|
||||
|
||||
public mounted(): void {
|
||||
new ResizeObserver(([first, ...rest]) => {
|
||||
if (rest.length > 0)
|
||||
console.warn(`Unexpected ${rest.length} extra entries!`);
|
||||
|
||||
this.store.set_calendar_aspect_ratio(first.contentRect);
|
||||
}).observe(this.$el);
|
||||
}
|
||||
|
||||
public transform_mouse_event(event: MouseEvent) {
|
||||
const point = get_event_thous(event);
|
||||
this.$emit(event.type, event, point);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
z-index: auto;
|
||||
}
|
||||
</style>
|
157
ui/src/components/editor/DoorCanvas.vue
Normal file
157
ui/src/components/editor/DoorCanvas.vue
Normal file
|
@ -0,0 +1,157 @@
|
|||
<template>
|
||||
<ThouCanvas
|
||||
@mousedown.left="draw_start"
|
||||
@mouseup.left="draw_finish"
|
||||
@mousedown.right="drag_start"
|
||||
@mouseup.right="drag_finish"
|
||||
@mousemove="on_mousemove"
|
||||
@click.middle="remove_rect"
|
||||
@dblclick.left="remove_rect"
|
||||
>
|
||||
<CalendarDoor
|
||||
v-for="(door, index) in doors"
|
||||
:key="`door-${index}`"
|
||||
:door="door"
|
||||
force_visible
|
||||
/>
|
||||
<SVGRect
|
||||
v-if="preview_visible"
|
||||
variant="success"
|
||||
:rectangle="preview_rect"
|
||||
visible
|
||||
/>
|
||||
</ThouCanvas>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Door } from "@/lib/door";
|
||||
import { Rectangle } from "@/lib/rectangle";
|
||||
import { Vector2D } from "@/lib/vector2d";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import CalendarDoor from "../calendar/CalendarDoor.vue";
|
||||
import SVGRect from "../calendar/SVGRect.vue";
|
||||
import ThouCanvas from "../calendar/ThouCanvas.vue";
|
||||
|
||||
enum CanvasState {
|
||||
Idle,
|
||||
Drawing,
|
||||
Dragging,
|
||||
}
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
CalendarDoor,
|
||||
SVGRect,
|
||||
ThouCanvas,
|
||||
},
|
||||
props: {
|
||||
doors: Array,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
private readonly min_rect_area = 300;
|
||||
private state = CanvasState.Idle;
|
||||
public preview_rect = new Rectangle();
|
||||
private drag_door?: Door;
|
||||
private drag_origin = new Vector2D();
|
||||
public doors!: Door[];
|
||||
|
||||
public get preview_visible(): boolean {
|
||||
return this.state !== CanvasState.Idle;
|
||||
}
|
||||
|
||||
private pop_door(point: Vector2D): Door | undefined {
|
||||
const idx = this.doors.findIndex((rect) => rect.position.contains(point));
|
||||
|
||||
if (idx === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.doors.splice(idx, 1)[0];
|
||||
}
|
||||
|
||||
public draw_start(event: MouseEvent, point: Vector2D) {
|
||||
if (this.preview_visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = CanvasState.Drawing;
|
||||
this.preview_rect = new Rectangle(point, point);
|
||||
}
|
||||
|
||||
public draw_finish() {
|
||||
if (this.state !== CanvasState.Drawing || this.preview_rect === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = CanvasState.Idle;
|
||||
|
||||
if (this.preview_rect.area < this.min_rect_area) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.doors.push(new Door(this.preview_rect));
|
||||
}
|
||||
|
||||
public drag_start(event: MouseEvent, point: Vector2D) {
|
||||
if (this.preview_visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.drag_door = this.pop_door(point);
|
||||
|
||||
if (this.drag_door === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = CanvasState.Dragging;
|
||||
this.drag_origin = point;
|
||||
|
||||
this.preview_rect = this.drag_door.position;
|
||||
}
|
||||
|
||||
public drag_finish() {
|
||||
if (
|
||||
this.state !== CanvasState.Dragging ||
|
||||
this.preview_rect === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = CanvasState.Idle;
|
||||
this.doors.push(new Door(this.preview_rect, this.drag_door!.day));
|
||||
}
|
||||
|
||||
public on_mousemove(event: MouseEvent, point: Vector2D) {
|
||||
if (this.preview_rect === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state === CanvasState.Drawing) {
|
||||
this.preview_rect = this.preview_rect.update(undefined, point);
|
||||
} else if (this.state === CanvasState.Dragging && this.drag_door) {
|
||||
const movement = point.minus(this.drag_origin);
|
||||
this.preview_rect = this.drag_door.position.move(movement);
|
||||
}
|
||||
}
|
||||
|
||||
public remove_rect(event: MouseEvent, point: Vector2D) {
|
||||
if (this.preview_visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pop_door(point);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
svg {
|
||||
cursor: crosshair;
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
47
ui/src/components/editor/DoorChooser.vue
Normal file
47
ui/src/components/editor/DoorChooser.vue
Normal file
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<div class="card-content">
|
||||
<div class="content is-small">
|
||||
<h3>Steuerung</h3>
|
||||
<ul>
|
||||
<li>Linksklick: Türchen bearbeiten</li>
|
||||
<li>Tastatur: Tag eingeben</li>
|
||||
<li>[Enter]: Tag speichern</li>
|
||||
<li>[Esc]: Eingabe Abbrechen</li>
|
||||
<li>[Entf]: Tag entfernen</li>
|
||||
</ul>
|
||||
</div>
|
||||
<figure class="image is-unselectable">
|
||||
<img :src="store.calendar_background_image" />
|
||||
<ThouCanvas>
|
||||
<PreviewDoor
|
||||
v-for="(door, index) in doors"
|
||||
:key="`door-${index}`"
|
||||
:door="door"
|
||||
/>
|
||||
</ThouCanvas>
|
||||
</figure>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Door } from "@/lib/door";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import ThouCanvas from "../calendar/ThouCanvas.vue";
|
||||
import PreviewDoor from "./PreviewDoor.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
ThouCanvas,
|
||||
PreviewDoor,
|
||||
},
|
||||
props: {
|
||||
doors: Array,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public doors!: Door[];
|
||||
public readonly store = advent22Store();
|
||||
}
|
||||
</script>
|
37
ui/src/components/editor/DoorPlacer.vue
Normal file
37
ui/src/components/editor/DoorPlacer.vue
Normal file
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<div class="card-content">
|
||||
<div class="content is-small">
|
||||
<h3>Steuerung</h3>
|
||||
<ul>
|
||||
<li>Linksklick + Ziehen: Neues Türchen erstellen</li>
|
||||
<li>Rechtsklick + Ziehen: Türchen verschieben</li>
|
||||
<li>Doppel- oder Mittelklick: Türchen löschen</li>
|
||||
</ul>
|
||||
</div>
|
||||
<figure class="image is-unselectable">
|
||||
<img :src="store.calendar_background_image" />
|
||||
<DoorCanvas :doors="doors" />
|
||||
</figure>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Door } from "@/lib/door";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import DoorCanvas from "./DoorCanvas.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
DoorCanvas,
|
||||
},
|
||||
props: {
|
||||
doors: Array,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public doors!: Door[];
|
||||
public readonly store = advent22Store();
|
||||
}
|
||||
</script>
|
89
ui/src/components/editor/PreviewDoor.vue
Normal file
89
ui/src/components/editor/PreviewDoor.vue
Normal file
|
@ -0,0 +1,89 @@
|
|||
<template>
|
||||
<SVGRect
|
||||
style="cursor: text"
|
||||
:rectangle="door.position"
|
||||
:variant="editing ? 'success' : 'primary'"
|
||||
@click.left="on_click"
|
||||
visible
|
||||
>
|
||||
<input
|
||||
v-if="editing"
|
||||
v-model="day_str"
|
||||
ref="day_input"
|
||||
class="input is-large"
|
||||
type="number"
|
||||
:min="MIN_DAY"
|
||||
placeholder="Tag"
|
||||
@keydown="on_keydown"
|
||||
/>
|
||||
<div v-else class="has-text-danger">
|
||||
{{ door.day > 0 ? door.day : "*" }}
|
||||
</div>
|
||||
</SVGRect>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Door } from "@/lib/door";
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import SVGRect from "../calendar/SVGRect.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
SVGRect,
|
||||
},
|
||||
props: {
|
||||
door: Door,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public door!: Door;
|
||||
public readonly MIN_DAY = Door.MIN_DAY;
|
||||
|
||||
public day_str = "";
|
||||
public editing = false;
|
||||
|
||||
private toggle_editing() {
|
||||
this.day_str = String(this.door.day);
|
||||
this.editing = !this.editing;
|
||||
}
|
||||
|
||||
public on_click(event: MouseEvent) {
|
||||
if (!(event.target instanceof HTMLDivElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.editing) {
|
||||
const day_input_focus = () => {
|
||||
if (this.$refs.day_input instanceof HTMLInputElement) {
|
||||
this.$refs.day_input.select();
|
||||
return;
|
||||
}
|
||||
|
||||
this.$nextTick(day_input_focus);
|
||||
};
|
||||
day_input_focus();
|
||||
} else {
|
||||
this.door.day = this.day_str;
|
||||
}
|
||||
|
||||
this.toggle_editing();
|
||||
}
|
||||
|
||||
public on_keydown(event: KeyboardEvent) {
|
||||
if (!this.editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
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>
|
10
ui/src/d.ts/shims-advent22.d.ts
vendored
Normal file
10
ui/src/d.ts/shims-advent22.d.ts
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Advent22 } from "@/plugins/advent22";
|
||||
|
||||
declare module "@vue/runtime-core" {
|
||||
// bind to `this` keyword
|
||||
interface ComponentCustomProperties {
|
||||
$advent22: Advent22;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
6
ui/src/d.ts/shims-vue.d.ts
vendored
Normal file
6
ui/src/d.ts/shims-vue.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
/* eslint-disable */
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
71
ui/src/lib/api.ts
Normal file
71
ui/src/lib/api.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
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 type Credentials = [username: string, password: string];
|
||||
|
||||
export function objForEach<T>(
|
||||
obj: T,
|
||||
f: (k: keyof T, v: T[keyof T]) => void,
|
||||
): void {
|
||||
for (const k in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, k)) {
|
||||
f(k, obj[k]);
|
||||
}
|
||||
}
|
||||
}
|
52
ui/src/lib/door.ts
Normal file
52
ui/src/lib/door.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { DoorSaved } from "./api";
|
||||
import { Rectangle } from "./rectangle";
|
||||
import { Vector2D } from "./vector2d";
|
||||
|
||||
export class Door {
|
||||
public static readonly MIN_DAY = 1;
|
||||
|
||||
private _day = Door.MIN_DAY;
|
||||
public position: Rectangle;
|
||||
|
||||
constructor(position: Rectangle);
|
||||
constructor(position: Rectangle, day: number);
|
||||
constructor(position: Rectangle, day = Door.MIN_DAY) {
|
||||
this.day = day;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public get day(): number {
|
||||
return this._day;
|
||||
}
|
||||
|
||||
public set day(day: unknown) {
|
||||
// integer coercion
|
||||
const result = Number(day);
|
||||
|
||||
if (isNaN(result)) {
|
||||
this._day = Door.MIN_DAY;
|
||||
} else {
|
||||
this._day = Math.max(Math.floor(result), Door.MIN_DAY);
|
||||
}
|
||||
}
|
||||
|
||||
public static load(serialized: DoorSaved): Door {
|
||||
return new Door(
|
||||
new Rectangle(
|
||||
new Vector2D(serialized.x1, serialized.y1),
|
||||
new Vector2D(serialized.x2, serialized.y2),
|
||||
),
|
||||
serialized.day,
|
||||
);
|
||||
}
|
||||
|
||||
public save(): DoorSaved {
|
||||
return {
|
||||
day: this.day,
|
||||
x1: this.position.origin.x,
|
||||
y1: this.position.origin.y,
|
||||
x2: this.position.corner.x,
|
||||
y2: this.position.corner.y,
|
||||
};
|
||||
}
|
||||
}
|
79
ui/src/lib/rectangle.ts
Normal file
79
ui/src/lib/rectangle.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
import { Vector2D } from "./vector2d";
|
||||
|
||||
export class Rectangle {
|
||||
private readonly corner_1: Vector2D;
|
||||
private readonly corner_2: Vector2D;
|
||||
|
||||
constructor();
|
||||
constructor(corner_1: Vector2D, corner_2: Vector2D);
|
||||
constructor(corner_1 = new Vector2D(), corner_2 = new Vector2D()) {
|
||||
this.corner_1 = corner_1;
|
||||
this.corner_2 = corner_2;
|
||||
}
|
||||
|
||||
public get origin(): Vector2D {
|
||||
return new Vector2D(
|
||||
Math.min(this.corner_1.x, this.corner_2.x),
|
||||
Math.min(this.corner_1.y, this.corner_2.y),
|
||||
);
|
||||
}
|
||||
|
||||
public get left(): number {
|
||||
return this.origin.x;
|
||||
}
|
||||
|
||||
public get top(): number {
|
||||
return this.origin.y;
|
||||
}
|
||||
|
||||
public get corner(): Vector2D {
|
||||
return new Vector2D(
|
||||
Math.max(this.corner_1.x, this.corner_2.x),
|
||||
Math.max(this.corner_1.y, this.corner_2.y),
|
||||
);
|
||||
}
|
||||
|
||||
public get size(): Vector2D {
|
||||
return this.corner.minus(this.origin);
|
||||
}
|
||||
|
||||
public get width(): number {
|
||||
return this.size.x;
|
||||
}
|
||||
|
||||
public get height(): number {
|
||||
return this.size.y;
|
||||
}
|
||||
|
||||
public get middle(): Vector2D {
|
||||
return this.origin.plus(this.size.scale(0.5));
|
||||
}
|
||||
|
||||
public get area(): number {
|
||||
return this.width * this.height;
|
||||
}
|
||||
|
||||
public equals(other: Rectangle): boolean {
|
||||
return this.origin.equals(other.origin) && this.corner.equals(other.corner);
|
||||
}
|
||||
|
||||
public contains(point: Vector2D): boolean {
|
||||
return (
|
||||
point.x >= this.origin.x &&
|
||||
point.y >= this.origin.y &&
|
||||
point.x <= this.corner.x &&
|
||||
point.y <= this.corner.y
|
||||
);
|
||||
}
|
||||
|
||||
public update(corner_1?: Vector2D, corner_2?: Vector2D): Rectangle {
|
||||
return new Rectangle(corner_1 || this.corner_1, corner_2 || this.corner_2);
|
||||
}
|
||||
|
||||
public move(vector: Vector2D): Rectangle {
|
||||
return new Rectangle(
|
||||
this.corner_1.plus(vector),
|
||||
this.corner_2.plus(vector),
|
||||
);
|
||||
}
|
||||
}
|
27
ui/src/lib/vector2d.ts
Normal file
27
ui/src/lib/vector2d.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
export class Vector2D {
|
||||
public readonly x: number;
|
||||
public readonly y: number;
|
||||
|
||||
constructor();
|
||||
constructor(x: number, y: number);
|
||||
constructor(x = 0, y = 0) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
public plus(other: Vector2D): Vector2D {
|
||||
return new Vector2D(this.x + other.x, this.y + other.y);
|
||||
}
|
||||
|
||||
public minus(other: Vector2D): Vector2D {
|
||||
return new Vector2D(this.x - other.x, this.y - other.y);
|
||||
}
|
||||
|
||||
public scale(other: number): Vector2D {
|
||||
return new Vector2D(this.x * other, this.y * other);
|
||||
}
|
||||
|
||||
public equals(other: Vector2D): boolean {
|
||||
return this.x === other.x && this.y === other.y;
|
||||
}
|
||||
}
|
64
ui/src/main.scss
Normal file
64
ui/src/main.scss
Normal file
|
@ -0,0 +1,64 @@
|
|||
@charset "utf-8";
|
||||
|
||||
//===========
|
||||
// variables
|
||||
//===========
|
||||
|
||||
// custom color scheme
|
||||
@import "@/bulma-scheme";
|
||||
|
||||
// Sass variables (bulma)
|
||||
@import "~bulma/sass/utilities/initial-variables.sass";
|
||||
@import "~bulma/sass/utilities/derived-variables.sass";
|
||||
|
||||
// Sass variables (bulma-prefers-dark)
|
||||
@import "~bulma-prefers-dark/sass/utilities/initial-variables.sass";
|
||||
@import "~bulma-prefers-dark/sass/utilities/derived-variables.sass";
|
||||
|
||||
//=================
|
||||
// variable tweaks
|
||||
//=================
|
||||
|
||||
$modal-card-body-background-color-dark: $body-background-dark;
|
||||
$card-background-color-dark: $background-dark;
|
||||
|
||||
//==============
|
||||
// main imports
|
||||
//==============
|
||||
|
||||
@import "~animate.css/animate";
|
||||
@import "~bulma/bulma";
|
||||
@import "~bulma-prefers-dark/bulma-prefers-dark";
|
||||
|
||||
//==============
|
||||
// style tweaks
|
||||
//==============
|
||||
|
||||
.card-header {
|
||||
background-color: $background;
|
||||
|
||||
@include prefers-scheme(dark) {
|
||||
background-color: $card-header-background-color;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@include prefers-scheme(dark) {
|
||||
background-color: $body-background-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
// &::-webkit-progress-bar {
|
||||
// background-color: transparent !important;
|
||||
// }
|
||||
// &::-webkit-progress-value {
|
||||
// background-color: transparent !important;
|
||||
// }
|
||||
&::-moz-progress-bar {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
// &::-ms-fill {
|
||||
// background-color: transparent !important;
|
||||
// }
|
||||
}
|
29
ui/src/main.ts
Normal file
29
ui/src/main.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { Advent22Plugin } from "@/plugins/advent22";
|
||||
import { FontAwesomePlugin } from "@/plugins/fontawesome";
|
||||
import { advent22Store } from "@/plugins/store";
|
||||
import * as bulmaToast from "bulma-toast";
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
import "@/main.scss";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(Advent22Plugin);
|
||||
app.use(FontAwesomePlugin);
|
||||
|
||||
app.use(createPinia());
|
||||
advent22Store().init();
|
||||
|
||||
app.mount("#app");
|
||||
|
||||
bulmaToast.setDefaults({
|
||||
duration: 10e3,
|
||||
pauseOnHover: true,
|
||||
dismissible: true,
|
||||
closeOnClick: false,
|
||||
type: "is-white",
|
||||
position: "top-center",
|
||||
animate: { in: "backInDown", out: "backOutUp" },
|
||||
});
|
110
ui/src/plugins/advent22.ts
Normal file
110
ui/src/plugins/advent22.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
import axios, { AxiosInstance, ResponseType } from "axios";
|
||||
import { App, Plugin } from "vue";
|
||||
import { advent22Store } from "./store";
|
||||
|
||||
export class Advent22 {
|
||||
private axios: AxiosInstance;
|
||||
|
||||
public constructor() {
|
||||
this.axios = axios.create({
|
||||
timeout: 10e3,
|
||||
});
|
||||
}
|
||||
|
||||
private get api_baseurl(): string {
|
||||
// in production mode, return "//host/api"
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return `//${window.location.host}/api`;
|
||||
} else if (process.env.NODE_ENV !== "development") {
|
||||
// not in prouction or development mode
|
||||
console.warn("Unexpected NODE_ENV value");
|
||||
}
|
||||
|
||||
// in development mode, return "//hostname:8000/api"
|
||||
return `//${window.location.hostname}:8000/api`;
|
||||
}
|
||||
|
||||
public name_door(day: number): string {
|
||||
return `Türchen ${day}`;
|
||||
}
|
||||
|
||||
public api_url(): string;
|
||||
public api_url(endpoint: string): string;
|
||||
public api_url(endpoint?: string): string {
|
||||
if (endpoint === undefined) {
|
||||
return this.api_baseurl;
|
||||
}
|
||||
|
||||
while (endpoint.startsWith("/")) {
|
||||
endpoint = endpoint.substring(1);
|
||||
}
|
||||
|
||||
return `${this.api_baseurl}/${endpoint}`;
|
||||
}
|
||||
|
||||
private _api_get<T>(endpoint: string): Promise<T>;
|
||||
private _api_get<T>(endpoint: string, responseType: ResponseType): Promise<T>;
|
||||
private _api_get<T>(
|
||||
endpoint: string,
|
||||
responseType: ResponseType = "json",
|
||||
): Promise<T> {
|
||||
const req_config = {
|
||||
auth: advent22Store().axios_creds,
|
||||
responseType: responseType,
|
||||
};
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.axios
|
||||
.get<T>(this.api_url(endpoint), req_config)
|
||||
.then((response) => resolve(response.data))
|
||||
.catch((reason) => {
|
||||
console.error(`Failed to query ${endpoint}: ${reason}`);
|
||||
reject([reason, endpoint]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public api_get<T>(endpoint: string): Promise<T> {
|
||||
return this._api_get<T>(endpoint);
|
||||
}
|
||||
|
||||
public api_get_blob(endpoint: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
this._api_get<Blob>(endpoint, "blob")
|
||||
.then((data: Blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(data);
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(["failed data url", endpoint]);
|
||||
}
|
||||
};
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
public api_put(endpoint: string, data: unknown): Promise<void> {
|
||||
const req_config = {
|
||||
auth: advent22Store().axios_creds,
|
||||
};
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.axios
|
||||
.put(this.api_url(endpoint), data, req_config)
|
||||
.then(() => resolve())
|
||||
.catch((reason) => {
|
||||
console.error(`Failed to query ${endpoint}: ${reason}`);
|
||||
reject([reason, endpoint]);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const Advent22Plugin: Plugin = {
|
||||
install(app: App) {
|
||||
app.config.globalProperties.$advent22 = new Advent22();
|
||||
},
|
||||
};
|
20
ui/src/plugins/fontawesome.ts
Normal file
20
ui/src/plugins/fontawesome.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { App, Plugin } from "vue";
|
||||
|
||||
/* import the fontawesome core */
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
/* import specific icons */
|
||||
import { fab } from "@fortawesome/free-brands-svg-icons";
|
||||
import { fas } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
/* add icons to the library */
|
||||
library.add(fas, fab);
|
||||
|
||||
/* import font awesome icon component */
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
|
||||
export const FontAwesomePlugin: Plugin = {
|
||||
install(app: App) {
|
||||
app.component("font-awesome-icon", FontAwesomeIcon);
|
||||
},
|
||||
};
|
233
ui/src/plugins/store.ts
Normal file
233
ui/src/plugins/store.ts
Normal file
|
@ -0,0 +1,233 @@
|
|||
import { Credentials, DoorSaved, SiteConfigModel } from "@/lib/api";
|
||||
import { Door } from "@/lib/door";
|
||||
import { Advent22 } from "@/plugins/advent22";
|
||||
import { RemovableRef, useLocalStorage } from "@vueuse/core";
|
||||
import { AxiosBasicCredentials, AxiosError } from "axios";
|
||||
import { toast } from "bulma-toast";
|
||||
import { acceptHMRUpdate, defineStore } from "pinia";
|
||||
|
||||
declare global {
|
||||
interface Navigator {
|
||||
readonly msMaxTouchPoints: number;
|
||||
}
|
||||
}
|
||||
|
||||
type State = {
|
||||
advent22: Advent22;
|
||||
api_creds: RemovableRef<Credentials>;
|
||||
is_initialized: boolean;
|
||||
on_initialized: (() => void)[];
|
||||
is_touch_device: boolean;
|
||||
is_admin: boolean;
|
||||
site_config: SiteConfigModel;
|
||||
calendar_background_image: string | undefined;
|
||||
calendar_aspect_ratio: number;
|
||||
user_doors: Door[];
|
||||
next_door_target: number | null;
|
||||
};
|
||||
|
||||
export const advent22Store = defineStore({
|
||||
id: "advent22",
|
||||
|
||||
state: (): State => ({
|
||||
advent22: new Advent22(),
|
||||
api_creds: useLocalStorage("advent22/auth", ["", ""]),
|
||||
is_initialized: false,
|
||||
on_initialized: [],
|
||||
is_touch_device:
|
||||
window.matchMedia("(any-hover: none)").matches ||
|
||||
"ontouchstart" in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0,
|
||||
is_admin: false,
|
||||
site_config: {
|
||||
title: document.title,
|
||||
subtitle: "",
|
||||
content: "",
|
||||
footer: "",
|
||||
},
|
||||
calendar_background_image: undefined,
|
||||
calendar_aspect_ratio: 1,
|
||||
user_doors: [],
|
||||
next_door_target: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
axios_creds: (state): AxiosBasicCredentials => {
|
||||
const [username, password] = state.api_creds;
|
||||
return { username: username, password: password };
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
init(): void {
|
||||
this.update()
|
||||
.then(() => {
|
||||
this.is_initialized = true;
|
||||
for (const callback of this.on_initialized) callback();
|
||||
})
|
||||
.catch(this.alert_user_error);
|
||||
},
|
||||
|
||||
format_user_error([reason, endpoint]: [unknown, string]): string {
|
||||
let msg =
|
||||
"Unbekannter Fehler, bitte wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
|
||||
let code = "U";
|
||||
const result = () => `${msg} (Fehlercode: ${code}/${endpoint})`;
|
||||
|
||||
if (!(reason instanceof AxiosError)) return result();
|
||||
|
||||
switch (reason.code) {
|
||||
case "ECONNABORTED":
|
||||
// API unerreichbar
|
||||
msg =
|
||||
"API antwortet nicht, bitte später wiederholen! Besteht das Problem länger, bitte Admin benachrichtigen!";
|
||||
code = "D";
|
||||
break;
|
||||
|
||||
case "ERR_NETWORK":
|
||||
// Netzwerk nicht verbunden
|
||||
msg = "Sieht aus, als sei deine Netzwerkverbindung gestört.";
|
||||
code = "N";
|
||||
break;
|
||||
|
||||
default:
|
||||
if (reason.response === undefined) return result();
|
||||
|
||||
switch (reason.response.status) {
|
||||
case 401:
|
||||
// UNAUTHORIZED
|
||||
msg = "Netter Versuch :)";
|
||||
code = "A";
|
||||
break;
|
||||
|
||||
case 422:
|
||||
// UNPROCESSABLE ENTITY
|
||||
msg = "Funktion ist kaputt, bitte Admin benachrichtigen!";
|
||||
code = "I";
|
||||
break;
|
||||
|
||||
default:
|
||||
// HTTP
|
||||
code = `H${reason.response.status}`;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return result();
|
||||
},
|
||||
|
||||
alert_user_error(param: [unknown, string]): void {
|
||||
toast({
|
||||
message: this.format_user_error(param),
|
||||
type: "is-danger",
|
||||
});
|
||||
},
|
||||
|
||||
update(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.advent22
|
||||
.api_get_blob("user/favicon")
|
||||
.then((favicon_src) => {
|
||||
const link: HTMLLinkElement =
|
||||
document.querySelector("link[rel*='icon']") ||
|
||||
document.createElement("link");
|
||||
link.rel = "shortcut icon";
|
||||
link.type = "image/x-icon";
|
||||
link.href = favicon_src;
|
||||
|
||||
if (link.parentElement === null)
|
||||
document.getElementsByTagName("head")[0].appendChild(link);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
Promise.all([
|
||||
this.update_is_admin(),
|
||||
this.advent22.api_get<SiteConfigModel>("user/site_config"),
|
||||
this.advent22.api_get_blob("user/background_image"),
|
||||
this.advent22.api_get<DoorSaved[]>("user/doors"),
|
||||
this.advent22.api_get<number | null>("user/next_door"),
|
||||
])
|
||||
.then(
|
||||
([
|
||||
is_admin,
|
||||
site_config,
|
||||
background_image,
|
||||
user_doors,
|
||||
next_door,
|
||||
]) => {
|
||||
is_admin; // discard value
|
||||
|
||||
document.title = site_config.title;
|
||||
|
||||
if (site_config.subtitle !== "")
|
||||
document.title += " – " + site_config.subtitle;
|
||||
|
||||
this.site_config = site_config;
|
||||
|
||||
this.calendar_background_image = background_image;
|
||||
|
||||
this.user_doors.length = 0;
|
||||
for (const door_saved of user_doors) {
|
||||
this.user_doors.push(Door.load(door_saved));
|
||||
}
|
||||
|
||||
if (next_door !== null)
|
||||
this.next_door_target = Date.now() + next_door;
|
||||
|
||||
resolve();
|
||||
},
|
||||
)
|
||||
.catch(reject);
|
||||
});
|
||||
},
|
||||
|
||||
when_initialized(callback: () => void): void {
|
||||
if (this.is_initialized) {
|
||||
callback();
|
||||
} else {
|
||||
this.on_initialized.push(callback);
|
||||
}
|
||||
},
|
||||
|
||||
update_is_admin(): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.advent22
|
||||
.api_get<boolean>("admin/is_admin")
|
||||
.then((is_admin) => {
|
||||
this.is_admin = is_admin;
|
||||
resolve(is_admin);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
},
|
||||
|
||||
login(creds: Credentials): Promise<boolean> {
|
||||
this.api_creds = creds;
|
||||
return this.update_is_admin();
|
||||
},
|
||||
|
||||
logout(): Promise<boolean> {
|
||||
return this.login(["", ""]);
|
||||
},
|
||||
|
||||
toggle_touch_device(): void {
|
||||
this.is_touch_device = !this.is_touch_device;
|
||||
},
|
||||
|
||||
set_calendar_aspect_ratio(rect: DOMRectReadOnly): void {
|
||||
const result = rect.width / rect.height;
|
||||
|
||||
// filter suspicious results
|
||||
if (result !== 0 && isFinite(result) && !isNaN(result))
|
||||
this.calendar_aspect_ratio = result;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (import.meta.webpackHot) {
|
||||
import.meta.webpackHot.accept(
|
||||
acceptHMRUpdate(advent22Store, import.meta.webpackHot),
|
||||
);
|
||||
}
|
90
ui/tests/unit/rectangle.spec.ts
Normal file
90
ui/tests/unit/rectangle.spec.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
import { expect } from "chai";
|
||||
|
||||
import { Rectangle } from "@/lib/rectangle";
|
||||
import { Vector2D } from "@/lib/vector2d";
|
||||
|
||||
describe("Rectangle Tests", () => {
|
||||
const v1 = new Vector2D(1, 2);
|
||||
const v2 = new Vector2D(4, 6);
|
||||
|
||||
const r1 = new Rectangle(v1, v2);
|
||||
const r2 = new Rectangle(v2, v1);
|
||||
|
||||
function check_rectangle(
|
||||
r: Rectangle,
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
expect(r.left).to.equal(left);
|
||||
expect(r.top).to.equal(top);
|
||||
|
||||
expect(r.width).to.equal(width);
|
||||
expect(r.height).to.equal(height);
|
||||
expect(r.area).to.equal(width * height);
|
||||
|
||||
expect(r.middle.x).to.equal(left + 0.5 * width);
|
||||
expect(r.middle.y).to.equal(top + 0.5 * height);
|
||||
}
|
||||
|
||||
it("should create a default rectangle", () => {
|
||||
check_rectangle(new Rectangle(), 0, 0, 0, 0);
|
||||
});
|
||||
|
||||
it("should create a rectangle", () => {
|
||||
check_rectangle(r1, 1, 2, 3, 4);
|
||||
});
|
||||
|
||||
it("should create the same rectangle backwards", () => {
|
||||
check_rectangle(r2, 1, 2, 3, 4);
|
||||
});
|
||||
|
||||
it("should compare rectangles", () => {
|
||||
expect(r1.equals(r2)).to.be.true;
|
||||
expect(r1.equals(new Rectangle())).to.be.false;
|
||||
});
|
||||
|
||||
it("should create the same rectangle transposed", () => {
|
||||
const v1t = new Vector2D(v1.x, v2.y);
|
||||
const v2t = new Vector2D(v2.x, v1.y);
|
||||
|
||||
expect(r1.equals(new Rectangle(v1t, v2t))).to.be.true;
|
||||
});
|
||||
|
||||
it("should contain itself", () => {
|
||||
expect(r1.contains(v1)).to.be.true;
|
||||
expect(r1.contains(v2)).to.be.true;
|
||||
|
||||
expect(r1.contains(r1.origin)).to.be.true;
|
||||
expect(r1.contains(r1.corner)).to.be.true;
|
||||
expect(r1.contains(r1.middle)).to.be.true;
|
||||
});
|
||||
|
||||
it("should not contain certain points", () => {
|
||||
expect(r1.contains(new Vector2D(0, 0))).to.be.false;
|
||||
expect(r1.contains(new Vector2D(100, 100))).to.be.false;
|
||||
});
|
||||
|
||||
it("should update a rectangle", () => {
|
||||
const v = new Vector2D(1, 1);
|
||||
|
||||
check_rectangle(r1.update(v1.plus(v), undefined), 2, 3, 2, 3);
|
||||
check_rectangle(r1.update(v1.minus(v), undefined), 0, 1, 4, 5);
|
||||
|
||||
check_rectangle(r1.update(undefined, v2.plus(v)), 1, 2, 4, 5);
|
||||
check_rectangle(r1.update(undefined, v2.minus(v)), 1, 2, 2, 3);
|
||||
|
||||
check_rectangle(r1.update(v1.plus(v), v2.plus(v)), 2, 3, 3, 4);
|
||||
check_rectangle(r1.update(v1.minus(v), v2.minus(v)), 0, 1, 3, 4);
|
||||
|
||||
check_rectangle(r1.update(v1.minus(v), v2.plus(v)), 0, 1, 5, 6);
|
||||
check_rectangle(r1.update(v1.plus(v), v2.minus(v)), 2, 3, 1, 2);
|
||||
});
|
||||
|
||||
it("should move a rectangle", () => {
|
||||
const v = new Vector2D(1, 1);
|
||||
|
||||
check_rectangle(r1.move(v), 2, 3, 3, 4);
|
||||
});
|
||||
});
|
41
ui/tests/unit/vector2d.spec.ts
Normal file
41
ui/tests/unit/vector2d.spec.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { expect } from "chai";
|
||||
|
||||
import { Vector2D } from "@/lib/vector2d";
|
||||
|
||||
describe("Vector2D Tests", () => {
|
||||
const v = new Vector2D(1, 2);
|
||||
|
||||
it("should create a default vector", () => {
|
||||
const v0 = new Vector2D();
|
||||
expect(v0.x).to.equal(0);
|
||||
expect(v0.y).to.equal(0);
|
||||
});
|
||||
|
||||
it("should create a vector", () => {
|
||||
expect(v.x).to.equal(1);
|
||||
expect(v.y).to.equal(2);
|
||||
});
|
||||
|
||||
it("should add vectors", () => {
|
||||
const v2 = v.plus(new Vector2D(3, 4));
|
||||
expect(v2.x).to.equal(4);
|
||||
expect(v2.y).to.equal(6);
|
||||
});
|
||||
|
||||
it("should subtract vectors", () => {
|
||||
const v2 = v.minus(new Vector2D(3, 4));
|
||||
expect(v2.x).to.equal(-2);
|
||||
expect(v2.y).to.equal(-2);
|
||||
});
|
||||
|
||||
it("should scale vectors", () => {
|
||||
const v2 = v.scale(3);
|
||||
expect(v2.x).to.equal(3);
|
||||
expect(v2.y).to.equal(6);
|
||||
});
|
||||
|
||||
it("should compare vectors", () => {
|
||||
expect(v.equals(v.scale(1))).to.be.true;
|
||||
expect(v.equals(v.scale(2))).to.be.false;
|
||||
});
|
||||
});
|
43
ui/tsconfig.json
Normal file
43
ui/tsconfig.json
Normal file
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useDefineForClassFields": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"webpack-env",
|
||||
"mocha",
|
||||
"chai"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
25
ui/vue.config.js
Normal file
25
ui/vue.config.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
const { defineConfig } = require("@vue/cli-service");
|
||||
const webpack = require("webpack");
|
||||
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true,
|
||||
devServer: {
|
||||
host: "localhost",
|
||||
},
|
||||
pages: {
|
||||
index: {
|
||||
entry: "src/main.ts",
|
||||
title: "Kalender-Gewinnspiel",
|
||||
},
|
||||
},
|
||||
// https://stackoverflow.com/a/77765007
|
||||
configureWebpack: {
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
// Vue CLI is in maintenance mode, and probably won't merge my PR to fix this in their tooling
|
||||
// https://github.com/vuejs/vue-cli/pull/7443
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
7548
ui/yarn.lock
Normal file
7548
ui/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue