Compare commits

..

521 commits

Author SHA1 Message Date
d0d81510cc store: catch failure to load from API 2024-08-26 19:14:04 +00:00
af5abc7c8f major refactoring
- use bulma 1.0
- use vue composition API for several components
2024-08-26 19:09:43 +00:00
109dec73d2 first part of refactoring
- use `ImageData` and `Loading` types for image transfer
-  consolidate library directory
- remove `advent22` plugin in favor of static `API` class
2024-08-26 14:14:07 +00:00
e3e19a0572 WIP: major refactoring 2024-08-23 18:59:11 +00:00
15b957791f WIP: major refactoring 2024-08-23 18:21:32 +00:00
55081b24d8 WIP: major refactoring 2024-08-23 16:38:04 +00:00
d8fe5dc9f4 prune fa-brands 2024-08-23 16:37:32 +00:00
0c01237456 UI upgrade deps
`yarn upgrade-interactive --latest`
2024-08-08 16:56:24 +00:00
04097ea1a3 fix vue cli-service feature flag warning 2024-04-03 19:00:20 +00:00
c6e61d5d76 move *_user_error from advent22.ts to store.ts, fixing advent22Store.init 2024-04-03 18:33:32 +00:00
98ab638762 API upgrade deps
`poetry up --latest`
2024-03-21 23:30:28 +01:00
06ece81349 UI upgrade deps
`yarn upgrade-interactive --latest`
2024-03-21 22:26:24 +00:00
82a2963ad7 cleanup/modernize devcontainers 2024-03-21 23:00:45 +01:00
2c1a190abb include matomo tracking 2023-11-27 18:52:00 +00:00
e2a14821ba update "store" from ConfigView 2023-11-24 11:57:33 +00:00
41889d9160 make subtle glow static 2023-11-24 11:34:18 +00:00
d396c2a8c3 add subtle glow to CalendarDoor numbers 2023-11-24 01:15:19 +00:00
f9f1de8987 make SVGRect a little less brutal 2023-11-24 01:53:52 +01:00
090da8c679 implement cfg.puzzle.extra_days 2023-11-24 01:44:32 +01:00
082f50c66b implement cfg.puzzle.skip_empty 2023-11-24 01:38:46 +01:00
d66019f53c minor improvements 2023-11-24 00:59:10 +01:00
7bc0ad21ba smaller app section border 2023-11-22 19:30:21 +00:00
053aa5d2d2 minor refactoring 2023-11-22 19:34:38 +01:00
ae9ca16aaa rename RE constants 2023-11-22 19:26:30 +01:00
878bf34d52 TransformedString into own file 2023-11-22 14:43:43 +01:00
10f41c34ae Idee 2023-11-22 14:37:51 +01:00
742f12aea5 add progress bar while loading 2023-11-21 23:42:30 +00:00
859f9586d3 bugfix: missing content when no doors open 2023-11-21 23:31:40 +00:00
0a095f3a0a DoorMapEditor "toast" duration 2023-11-21 23:22:56 +00:00
56db55681a "e" number formatting 2023-11-21 23:22:28 +00:00
b734dee575 bugfix: cache entry invalidation
- davkey has no "cls" argument
- needs explicit slice
2023-11-22 00:10:37 +01:00
77dd575e72 upgrade dependencies
- poetry up --latest
- yarn upgrade --latest
2023-11-22 00:08:05 +01:00
adbe740edf remove declare $refs 2023-11-21 22:37:00 +00:00
6d4ec2cbe7 CalendarDoor cursor style 2023-11-21 22:33:11 +00:00
08fc47a2e3 ui: add launch/tasks config 2023-11-21 22:25:12 +00:00
b4ab4809c6 handling special chars in solution 2023-11-21 23:20:22 +01:00
cdf977f0db whitespace handling changes 2023-11-21 22:58:12 +01:00
a86e47113c user name case sensitivity 2023-11-21 22:54:37 +01:00
489d64414e update Ideendokument 2023-11-11 02:18:43 +00:00
4e66d304e5 Advent22.alert_user_error: Use bulma-toast 2023-11-11 02:07:42 +00:00
785c74fd7b Calendar: hide toast on unmount 2023-11-11 01:47:54 +00:00
8c93a974e6 add store.on_initialized hooks
- start Calendar.toast_timeout when initialized
2023-11-11 01:47:18 +00:00
a63344dfe1 add calendar_background_image into store 2023-11-11 01:44:13 +00:00
835bc6d2d0 Calendar: Use BulmaToast 2023-11-11 01:22:30 +00:00
4cb17c1b6e BulmaToast component 2023-11-11 00:26:10 +00:00
f6f7381745 vscode volar disable inlay hints 2023-11-10 21:55:40 +00:00
9e9481217d BulmaButton: don't show undefined icon 2023-11-10 21:55:21 +00:00
15309e6710 Merge branch 'feature/remove-svg-rect' into develop 2023-11-09 23:28:55 +00:00
223cc584a7 store.user_doors handling 2023-11-09 23:14:19 +00:00
9e303f898a fixed some code smells 2023-11-09 23:08:35 +00:00
e5364ff94d move "hover title" to Calendar instead 2023-11-09 18:48:13 +00:00
3b22f09a13 CalendarDoor hover title 2023-11-09 18:31:07 +00:00
3da3f7f639 put store.api_creds into localStorage
- don't need beforeunload in AdminView anymore
2023-11-09 18:19:48 +00:00
f9111e7a45 Fix for PreviewDoor 2023-11-09 18:03:10 +00:00
c2892f6358 wip: Fix for DoorCanvas
- PreviewDoor still broken
2023-11-09 17:51:56 +00:00
194a7bb0db Merge branch 'develop' into feature/remove-svg-rect 2023-11-09 13:00:00 +01:00
2cf6936139 env file setup 2023-11-09 12:58:36 +01:00
02ba2c67a5 simpler helpers.davkey 2023-11-09 12:55:10 +01:00
eef38502c6 workspace directory in container 2023-11-09 12:54:35 +01:00
8cd4e922ff unneeded ref in SVGRect 2023-11-06 00:37:03 +00:00
2282de2528 SVGRect style optimization 2023-11-06 00:32:56 +00:00
8b581a7b50 Merge branch 'develop' into feature/remove-svg-rect 2023-11-06 00:21:10 +00:00
f9583571ed scss color scheme improvements 2023-11-06 00:03:23 +00:00
53bfdfd7b5 wip: more elegant solution; breaks DoorCanvas, PreviewDoor 2023-11-05 23:51:16 +00:00
5213e0b8b1 Merge branch 'develop' into feature/remove-svg-rect 2023-11-05 22:40:49 +00:00
e613ead635 "calendar_aspect_ratio" into store 2023-11-05 22:36:58 +00:00
9413b96c1a "touch mode" improvement 2023-11-05 21:57:50 +00:00
dcbcdc425a general UI improvements 2023-11-05 21:37:05 +00:00
0eeaaef4bf Merge branch 'develop' into feature/remove-svg-rect 2023-11-04 16:13:15 +00:00
11cb3fef38 UserView layout improvement 2023-11-04 16:12:45 +00:00
9b34b9de8c preliminary app name and noscript message 2023-11-04 16:01:58 +00:00
bd0c4e4abd improve vue app lifecycle 2023-11-04 15:58:31 +00:00
e2e4847eea wip: works, but style really broken 2023-11-04 04:33:54 +00:00
4654032fa0 UserView layout optimization 2023-11-04 04:21:01 +00:00
779ccd8e65 update Ideendokument 2023-11-04 04:18:08 +00:00
d61c27deca Calendar remove figcaption 2023-11-04 04:17:53 +00:00
644b1eb3e3 add user_doors into store 2023-11-04 04:17:36 +00:00
6efa2aef9b show CalendarDoor on hover 2023-11-04 01:31:49 +00:00
b6e24dae9d update Ideendokument 2023-11-04 01:31:33 +00:00
ca8df3ba10 move user/next_door call to "store" 2023-11-03 17:07:25 +00:00
4fbdb94caa cfg.site with markdown 2023-11-03 14:40:44 +00:00
6d015420c0 little unification 2023-11-02 12:49:12 +00:00
0bc31529bc ConfigModel -> AdminConfigModel 2023-11-02 12:49:02 +00:00
600ee99520 favicon handling 2023-11-02 12:48:52 +00:00
45ad00eaa6 UserView content 2023-11-02 01:12:41 +00:00
adde270f9f main app layout fixes 2023-11-02 01:12:20 +00:00
dae862fc55 add and use $advent22.alert_user_error 2023-11-02 00:41:42 +00:00
8e4ea78873 move api credentials to pinia store 2023-11-02 00:37:00 +00:00
a0b576e14d added plugins/store.ts using "pinia" to define input mode 2023-11-01 23:58:09 +00:00
19411351dd mobile layout optimization; auto-show doors on touch device 2023-11-01 22:41:30 +00:00
066b6686d8 MultiModal visibility 2023-11-01 02:00:23 +00:00
39d375ced0 pre-create xy_range 2023-11-01 02:34:04 +01:00
e4f9ded5c8 Show time to next door in Calendar component 2023-11-01 01:29:37 +00:00
59cf7202f4 updated title handling 2023-11-01 01:12:03 +00:00
5683438a19 Ideendokument 2023-11-01 01:54:57 +01:00
d0bdc62433 use API route user/title in UI 2023-11-01 01:54:46 +01:00
b8c30d130a Ideendokument 2023-11-01 01:32:18 +01:00
8a254d2958 updated font handling 2023-11-01 01:30:33 +01:00
7951363be8 gen_day_auto_image doesnt resolve Depends() 2023-10-31 23:59:21 +01:00
a1729d13f7 common code for helpers.list_* functions 2023-10-31 23:43:38 +01:00
2a9635c8c1 refac: get_all_image_names using new helper list_images_manual 2023-10-31 23:35:24 +01:00
367fef145d RedisSettings 2023-10-31 21:48:27 +00:00
63b9f4e1d9 make cfg.solution.* values non-null 2023-10-31 22:36:35 +01:00
penner
aaa12683c8 Todo 3 2023-10-31 21:23:34 +00:00
penner
84c5467edb Todo 2 2023-10-31 20:40:07 +00:00
penner
337d8d34ff Merge branch 'develop' of ssh://code.yavook.de:22022/Zaphlebeod/advent22 into develop 2023-10-31 20:12:05 +00:00
penner
558a7e8a02 todo penner 1 2023-10-31 21:07:09 +01:00
cc54e1dddf show "redis" settings in ConfigView 2023-10-31 20:27:50 +01:00
b74646994e cfg.puzzle.solution -> cfg.solution 2023-10-31 20:18:18 +01:00
b422913d7c Disallow MultiModal dismissal while loading 2023-10-31 18:42:01 +00:00
6ad52f8996 docker build staging 2023-10-30 19:47:47 +01:00
e1b228603e bug: comma placement in ConfigView 2023-10-30 19:47:47 +01:00
a781240af0 reformat pyproject.toml 2023-10-30 19:47:47 +01:00
2e50dd447d use compose environment for devcontainer
- add redis container
2023-10-30 19:47:47 +01:00
b206b472bc use Redis to cache WebDAV results 2023-10-30 19:47:43 +01:00
bf6d72147e confirm reload in admin view 2023-10-28 23:51:57 +02:00
3d62486783 config.puzzle.solution transformations 2023-10-28 23:48:37 +02:00
33733adc01 update Ideendokument 2023-10-27 23:23:28 +02:00
beaaa2b11d add local "black" formatter 2023-10-27 23:12:55 +02:00
d1cde05be7 better async webdav implementation 2023-10-27 23:12:28 +02:00
5e5b2b164e Calendar component: bulma level fix responsiveness 2023-10-27 20:52:06 +00:00
f91b3d17d7 force footer bottom 2023-10-27 20:48:25 +00:00
fe90960e6a api: update project scaffolding 2023-10-27 20:55:20 +02:00
87856b06db Dockerfile build args 2023-10-27 15:42:11 +00:00
6f09010d0d Door opening logic into Calendar component 2023-10-27 15:37:21 +00:00
ce29116e88 Dockerfile build args 2023-10-27 15:10:25 +00:00
0567cd9a4f main app layout optimization 2023-10-27 15:10:07 +00:00
7a3042a2b5 <style> tag placement 2023-10-27 15:09:44 +00:00
a72ba92f5e Merge branch 'wip/rect_rework' into develop 2023-10-27 15:05:32 +00:00
d8e011accc Show door days in DoorPlacer
RectangleCanvas -> DoorCanvas
2023-10-27 15:04:07 +00:00
70d3489a29 toggle door visibility 2023-10-27 14:40:25 +00:00
b3a4a20f16 Merge branch 'develop' into wip/rect_rework 2023-10-06 16:33:59 +00:00
3a3dfcd94d show door number in ConfigView 2023-09-26 22:23:43 +00:00
1d760f91db customizable footer 2023-09-26 22:23:23 +00:00
6cbb15dc69 Merge branch 'develop' into wip/rect_rework 2023-09-22 23:48:17 +00:00
62f570aaca "spread" Funktion 2023-09-23 01:45:27 +02:00
a15dd4fdfa AdminButton fragment handling 2023-09-22 20:42:28 +00:00
cfea9b051f Rework SVGRect and Doors 2023-09-22 19:02:51 +00:00
6e8b6549ea more Credentials cleanup 2023-09-21 20:01:39 +00:00
de5fff311c bug: drop v-model for mutable objects 2023-09-21 13:29:47 +00:00
5fcc4f0684 various cleanup 2023-09-21 12:25:23 +00:00
5e0f797a2f config rework & bigger images 2023-09-21 13:49:28 +02:00
c35fe495dc improve CalendarConfig.change function 2023-09-21 13:26:33 +02:00
be6c03c84a rework _security to respect EventDates 2023-09-21 13:26:02 +02:00
558f8d22ff mistyped dependency 2023-09-21 13:22:27 +02:00
ff04499a02 DoorMapEditor show live door list 2023-09-21 10:03:53 +00:00
b99a6ccc68 typing improvements 2023-09-21 09:53:30 +00:00
7fc0d82354 bug in EventDays usage 2023-09-21 10:58:33 +02:00
d0c43fb4c8 minor UI changes 2023-09-21 08:52:35 +00:00
7bc94804e3 remove DayStrModel 2023-09-21 00:45:57 +00:00
24e9c93eef CalendarAssistant.day_data 2023-09-21 00:19:44 +00:00
e83f56a932 hide openapi, docs and redoc in production mode 2023-09-21 00:56:25 +02:00
9063ebe93a SVGRect "variant" prop fitting color scheme 2023-09-20 22:26:40 +00:00
bc4054ef73 double "button" class on BulmaButton 2023-09-20 22:26:40 +00:00
6ba3947cb8 some layout tweaks 2023-09-20 22:26:40 +00:00
8b003419a5 upgrade dependencies
- `poetry self add poetry-plugin-up`
- `poetry up --latest`
- `yarn upgrade --latest`
2023-09-20 23:21:26 +02:00
8093b78af1 Dockerfile caching optimization 2023-09-20 19:04:43 +02:00
855897eda7 Node.js version fixed
weird problem with Node.js v18.18
2023-09-20 19:03:24 +02:00
0c28febf1f idea 2023-09-20 16:30:00 +00:00
d5ae079949 typo 2023-09-20 18:24:19 +02:00
cc94aace76 Ideendokument 2023-09-20 16:19:33 +00:00
0fcae295ec minor renamings 2023-09-20 18:14:58 +02:00
eef06ed131 bug: issue with short solutions 2023-09-20 16:39:00 +02:00
82ab9ccddc EventDays -> EventDates rework
- events param is now one-indexed
2023-09-20 16:25:10 +02:00
75dcea25fb revert accidental 12 -> 9 change 2023-09-19 18:50:43 +02:00
d4c0d1ef5e WIP: EventDays rework 2023-09-19 18:49:10 +02:00
8626b1460a last touches to the Dockerfile 2023-09-19 18:05:14 +02:00
b644252b9c minor rename / doc 2023-09-18 23:12:32 +02:00
3bf46847e9 minor layout tweaks 2023-09-18 20:48:15 +00:00
b4a4a3990b switch .section and .container 2023-09-18 20:48:15 +00:00
411d1492f4 Dockerfile best practices 2023-09-18 21:03:38 +02:00
126feaacdd API devcontainer timezone 2023-09-18 20:37:30 +02:00
1b71aab5b6 cleaner dockerization 2023-09-18 18:17:28 +00:00
penner
2d864dd57f DockerImage 2023-09-16 21:57:19 +00:00
penner
776298a357 Bug Found 2023-09-16 20:54:33 +00:00
dbf6d72d1f layout title with AdminButton 2023-09-16 00:52:59 +00:00
77cb0c1da2 ConfigView luxon date formatting 2023-09-16 00:17:13 +00:00
f7dc74d508 unused Date object 2023-09-16 00:16:57 +00:00
c55bef46dd bug: NoneValue for ConfigModel.puzzle.next 2023-09-16 00:16:18 +00:00
bf2fa124e7 fix for dark indeterminate progress bar (firefox) 2023-09-15 23:49:38 +00:00
6611b228c1 CountDown revamp with luxon 2023-09-15 23:20:15 +00:00
444c850361 idea 2023-09-15 17:13:15 +00:00
1bfa17a629 ConfigModel.puzzle.next 2023-09-15 17:06:28 +00:00
2394bd19a5 ConfigView.next_door === null 2023-09-15 17:01:38 +00:00
513b6ecf10 ConfigView.next_door 2023-09-15 16:55:03 +00:00
6bd8f66527 GET user/next_door 2023-09-15 16:54:45 +00:00
41368c7191 doc 2023-09-15 16:54:29 +00:00
c93ec06925 use EventDays in GET admin/config_model 2023-09-14 23:40:06 +00:00
a4a0893e7d minor doc + refactoring 2023-09-14 23:38:35 +00:00
97d4d1e136 helpers.EventDays class + basic testing 2023-09-14 23:36:20 +00:00
e9ef7f67e3 remove public puzzle.title from private ConfigModel 2023-09-14 15:00:34 +00:00
47eb42f0f5 missing catch in api_get_blob 2023-09-14 14:44:41 +00:00
da09db3bda stray console.log 2023-09-14 14:36:05 +00:00
597f01b545 cleanup some $refs 2023-09-14 14:20:21 +00:00
1b42cba9fa DoorMapEditor level-items 2023-09-14 13:57:20 +00:00
6b28c08fb9 Colorscheme and Layout optimizations 2023-09-14 13:54:23 +00:00
0498a722b1 BulmaDrawer remove prop state 2023-09-14 12:51:30 +00:00
610a7838c0 BulmaSecret: click_state -> state 2023-09-14 04:00:04 +00:00
106797a70a BulmaDrawer: enum DrawerState 2023-09-14 03:59:33 +00:00
73f80ae36d dark mode + style tweaks 2023-09-14 03:36:42 +00:00
f7e98ed6f7 Peterprobleme 2023-09-13 19:20:11 +00:00
4143b4e415 UserView and AdminView components 2023-09-13 16:20:52 +00:00
c43cbd507f unnecessary "prop.required" 2023-09-13 16:20:30 +00:00
073a9d5e5a subdir components/admin 2023-09-13 16:08:05 +00:00
5b4e057279 BulmaDrawer failed property 2023-09-13 15:58:15 +00:00
421f7d185c User Error Formatting 2023-09-13 15:24:25 +00:00
c4b1c180e0 AdminButton on_cancel 2023-09-13 15:20:52 +00:00
9ebf50218c DoorMapEditor "busy" state + error handling 2023-09-13 14:07:09 +00:00
47375105ac fix image url 2023-09-12 22:49:18 +00:00
dd40fa9392 error handling 2023-09-12 22:42:51 +00:00
5f2c031793 BulmaButton "busy" animation 2023-09-12 22:35:57 +00:00
6012cec657 fix: Advent22.api_put authorization 2023-09-12 22:35:40 +00:00
bd7bc38954 endpoints "credentials" (routers.admin) 2023-09-12 22:06:16 +00:00
2d0209ae61 ConfigView.num_user_doors 2023-09-12 21:06:52 +00:00
60a451a426 router: "images" -> "user" 2023-09-12 20:51:26 +00:00
d7818ac93a router.images: unnecessary "startup" 2023-09-12 20:46:25 +00:00
764c6b16fb config.puzzle.title 2023-09-12 20:45:57 +00:00
4686ab9c56 BulmaDrawer loading animation 2023-09-12 20:39:31 +00:00
05f5bed4d0 Advent22.name_door 2023-09-12 17:46:09 +00:00
db5b056866 MultiModal.figure_caption 2023-09-12 17:42:38 +00:00
d4f8469a7a depends.get_all_image_names, CalendarAssistant.day_image_names 2023-09-12 17:31:08 +00:00
0550621835 minor renames 2023-09-12 17:22:52 +00:00
8290925659 minor cleanup 2023-09-12 17:16:02 +00:00
5694438c96 rename DayPartModel -> DayStrModel 2023-09-12 17:15:44 +00:00
3ccd3da28a Merge branch 'feature/days-rework' into develop 2023-09-12 16:57:06 +00:00
51beac231c more interfaces into api.ts 2023-09-12 16:55:34 +00:00
a24773ec64 Merge branch 'develop' into feature/days-rework 2023-09-12 16:50:32 +00:00
d3aded71bd Ideen 2023-09-12 16:49:47 +00:00
fd821d6ff2 _security indexing 2023-09-12 16:48:55 +00:00
629c6d8f9d CalendarAssistant day_parts 2023-09-12 16:39:52 +00:00
d1e2648f5f API models into own TS module 2023-09-12 16:39:18 +00:00
4e1a9fa10d splice DayPartModel from ConfigModel 2023-09-12 16:26:12 +00:00
334540187e Drawer better toggle logic 2023-09-12 16:11:03 +00:00
c7fd54f60d ui mini buttons 2023-09-12 15:58:56 +00:00
ddb893ea6c DoorMapEditor Drawer usage 2023-09-12 15:51:23 +00:00
dbd0e4962c Drawer layout optimization 2023-09-12 15:46:45 +00:00
2f4842f539 refreshable Drawer 2023-09-12 15:39:54 +00:00
0bf2ce0b9e ConfigView loading flow 2023-09-12 15:10:54 +00:00
3892276313 Merge branch 'develop' into feature/days-rework 2023-09-12 14:57:00 +00:00
3aa59c73df Ideendokument 2023-09-12 14:56:28 +00:00
c718ab7d42 UI: one-indexed days 2023-09-12 14:55:08 +00:00
f5825ae19b mark TODOs for ConfigView 2023-09-12 13:59:47 +00:00
a49ed8dd79 ConfigView doors list 2023-09-12 13:56:05 +00:00
20375f2c9d reflect api changes in UI 2023-09-12 13:55:08 +00:00
3316bf2822 major cleanup
- `routers`: `admin`, `days`, `general`, `user` -> `admin`, `images`
- `core.depends`: cleanup
- `core.*_helpers`: joined in `core.helpers`
2023-09-12 13:50:02 +00:00
63d88c3a09 implementation using get_part_for_day 2023-09-12 07:27:59 +00:00
a84323afc8 Merge branch 'develop' into feature/days-rework 2023-09-12 06:36:12 +00:00
a11c16d17b more asyncification for webdav 2023-09-12 06:36:02 +00:00
95dfe2a9df depends.py: get_days and get_solution_parts 2023-09-12 02:58:10 +00:00
b73185e4fb remove "doors" from admin ConfigModel (is public) 2023-09-11 23:47:07 +00:00
2ae4fb8695 readability 2023-09-11 23:41:45 +00:00
08972df4cc cleanup event listener in MultiModal 2023-09-11 23:37:37 +00:00
b30e8095f9 way better admin login flow 2023-09-11 23:36:36 +00:00
2da7b6914a crude admin authentication flow 2023-09-11 23:10:17 +00:00
26556ad309 ConfigView rendering 2023-09-11 22:50:11 +00:00
74b9322ae2 query admin/config_model in ConfigView 2023-09-11 22:24:01 +00:00
d5d99caeb2 cfg.puzzle.random_pepper -> cfg.puzzle.random_seed 2023-09-11 22:14:33 +00:00
0337c659a2 Depends(shuffle_solution) 2023-09-11 19:40:36 +00:00
91c2589045 "admin" router with /private_config route 2023-09-11 19:39:02 +00:00
02cb654022 hack handle key miss 2023-09-11 03:12:24 +00:00
4415c0f861 hack: invalidate cache on file write 2023-09-11 02:59:11 +00:00
5c583bd478 TTL caching improvement 2023-09-11 02:37:08 +00:00
b00fdee126 minor UI tweaks 2023-09-11 02:36:51 +00:00
f4c8ab0b5a error handling improvements 2023-09-10 21:55:25 +00:00
f9369ec204 App div -> section 2023-09-10 21:09:00 +00:00
6e97420f1b CalendarAssistant component 2023-09-10 21:08:42 +00:00
451fd6bb88 ConfigView overflow auto 2023-09-10 20:08:13 +00:00
657a7507f7 manual image processing 2023-09-10 15:22:45 +00:00
penner
050c8408d9 No negative Days 2023-09-10 15:12:58 +00:00
4684df16f9 BulmaDrawer bottom margin 2023-09-10 14:09:50 +00:00
6f73440160 ConfigView into thirds (breaks long <dd>) 2023-09-10 03:54:54 +00:00
0d07f67642 minor tweaks 2023-09-10 03:48:50 +00:00
684443e6a6 bulma/Drawer using "card" 2023-09-10 03:38:24 +00:00
a645dc84cc ConfigView using bulma "card" 2023-09-10 03:25:12 +00:00
5b5d85e9d9 ConfigView component (mockup content) 2023-09-10 03:10:22 +00:00
8389ceb14a api: minor cfg/settings adjustment 2023-09-10 02:59:57 +00:00
c4181dc5f8 move stuff around 2023-09-10 01:59:19 +00:00
99e9f5980f bulma Button minor oversight 2023-09-10 00:54:49 +00:00
d4bd41fc67 remove bulma Drawer slot "heading" 2023-09-10 00:54:29 +00:00
cfeb87401e flex "space-around" for button groups 2023-09-10 00:25:22 +00:00
943c9b6c32 AdminButton component 2023-09-10 00:24:56 +00:00
ce344e5043 subdir for "bulma" components 2023-09-10 00:10:54 +00:00
5c38c2fcd8 DoorMapEditor minor adjustments 2023-09-09 23:53:49 +00:00
6e4578ec5e Calendar <figcaption> 2023-09-09 23:52:58 +00:00
74c22fc8a6 BulmaDrawer "caret" -> "angle" icon 2023-09-09 23:19:46 +00:00
c5680f37f6 DoorPlacer/DoorChooser with panel-block 2023-09-09 23:08:43 +00:00
f75f5689de DoorMapEditor layout improvements 2023-09-09 22:43:11 +00:00
63f05c1fb9 DoorMapEditor fully usable 2023-09-09 22:18:16 +00:00
3ab2a56172 get_visible_days with Depends() 2023-09-09 21:41:30 +00:00
5ca3112619 stylesheets improvements 2023-09-09 21:41:06 +00:00
3153f57613 Merge branch 'feature/api-refactoring' into develop 2023-09-08 20:02:24 +00:00
e51744ddc9 bug: calendar config filename 2023-09-08 19:59:26 +00:00
f9f9989414 remove (Calendar)Config's static methods for good measure 2023-09-08 19:53:35 +00:00
21279f747c documentation 2023-09-08 19:44:41 +00:00
11daf2bf8b bug: set_calendar_config nicht "dependable" 2023-09-08 19:43:53 +00:00
973f7546b6 module routers._security for user/admin stuff 2023-09-08 19:33:43 +00:00
29f02d2545 remove "namespace classes" AllTime and Today 2023-09-08 19:19:08 +00:00
af00dafb6c router integration: stuck
apparently, a @staticmethod that Depends on another @staticmethod in the same class is bad
2023-09-08 19:08:13 +00:00
b1748ea0fb "core" module alpha state 2023-09-08 18:17:18 +00:00
d51db8b836 apply experiments to WebDAV helper 2023-09-08 16:19:26 +00:00
e894aad746 experimental success 2023-09-08 16:08:10 +00:00
5223efddb4 wrapping experiments 2023-09-08 15:54:11 +00:00
d27d952d38 async context manager for WebDAV.read_buffer 2023-09-08 15:30:52 +00:00
6ac19ee866 Merge branch 'develop' into feature/refactoring 2023-09-08 14:53:29 +00:00
14896960c2 use zsh terminal in api 2023-09-08 14:48:48 +00:00
74688f45b8 typing issues 2023-09-08 11:27:23 +00:00
c8ce12c299 Merge branch 'develop' into feature/refactoring 2023-09-08 11:14:01 +00:00
d1158b1e10 fix typing issues 2023-09-08 11:13:51 +00:00
69e4590305 "TODO penner" 2023-09-08 10:23:43 +00:00
452040a0ae WIP: core module 2023-09-08 02:45:00 +00:00
30c5788d4d cache_ttl default 2023-09-08 01:39:53 +00:00
5cf0846c68 basic readability 2023-09-08 01:19:15 +00:00
a5751c973a config structure rework 2023-09-08 00:56:14 +00:00
a0268f52b9 sort doors on set 2023-09-07 21:18:08 +00:00
2e0004bfcb config exclude 2023-09-07 21:11:16 +00:00
d05e3b94be documentation 2023-09-07 21:05:00 +00:00
90c3643bf1 open/close DoorMapEditor 2023-09-07 20:54:11 +00:00
83098d3dc4 minor refactoring 2023-09-07 19:34:11 +00:00
7821aebd68 layout improvements (more bulma) 2023-09-07 17:32:53 +00:00
a9754f77bc use Advent22 api_get<T> 2023-09-07 17:00:28 +00:00
27db0ea908 BulmaButton icon optional 2023-09-07 16:51:19 +00:00
b3448dbfa8 get/set doors 2023-09-07 16:44:44 +00:00
62a93d41c7 BulmaButton component 2023-09-07 15:43:14 +00:00
35257b227a Advent22 api_url overloads 2023-09-07 15:15:12 +00:00
79fc7dc89d Advent22 axios timeout 2023-09-07 15:13:10 +00:00
1866833d3d Promise based Advent22.api_get 2023-09-07 15:12:46 +00:00
a6e6d8eb19 (de-)serializing of "Door" class 2023-09-07 03:32:15 +00:00
dd156b936d ts overload constructors 2023-09-07 03:29:53 +00:00
0dd96f5949 LoginModal: autofocus and some fixes 2023-09-07 02:33:45 +00:00
9cd2e51eb8 specify left click on some components 2023-09-07 02:12:34 +00:00
f603a9947c refactoring (moving stuff around) 2023-09-07 02:08:56 +00:00
3ce2480dcc Merge branch 'feature/prettier' into develop 2023-09-07 01:17:44 +00:00
40284c4061 apply "prettier" styling 2023-09-07 01:17:14 +00:00
4be3e38962 add "prettier" formatter 2023-09-07 01:17:00 +00:00
84be0cbc71 ui: add git-flow to devcontainer 2023-09-07 00:55:35 +00:00
f202f78d51 create Calendar component 2023-09-07 00:41:38 +00:00
560beb0a44 improve Door integer coercion, remove PreviewDoor.editable 2023-09-07 00:34:42 +00:00
3a4d2c1598 recommend "vue-vscode-snippets" extension 2023-09-07 00:33:33 +00:00
9f3339240f be a bit safer 2023-09-06 23:18:55 +00:00
d26a75ebcc branch out "sandbox" stuff 2023-09-06 18:46:37 +00:00
23820bc9dc open door progress + fail 2023-09-06 18:44:19 +00:00
391f3e8fba VSCode: "octref.vetur" -> "Vue.volar" 2023-09-06 16:25:35 +00:00
17b6950491 Config.puzzle.font 2023-09-04 21:42:58 +00:00
05750d6de2 select() on PreviewDoor click 2023-09-04 21:27:37 +00:00
dd519ec3cc load background image from WebDAV 2023-09-04 21:23:27 +00:00
f7754ec901 fix non-integer door numbers 2023-09-04 20:47:45 +00:00
e097e3011e Merge branch 'feature/drawrects' into develop 2023-09-04 22:20:58 +02:00
d159485b2c Merge branch 'develop' into feature/drawrects 2023-09-04 20:03:48 +00:00
0767e91d7d minor format 2023-09-04 20:02:43 +00:00
61f8b7bbe6 Merge branch 'develop' into feature/drawrects 2023-09-03 20:42:15 +00:00
566da81a20 suppress frozen modules warning
- Xfrozen_modules=off **is** passed to process
2023-09-03 20:41:20 +00:00
00df526099 yarn upgrade --latest 2023-09-03 19:38:30 +00:00
eb1f421b0d Merge branch 'develop' into feature/drawrects 2023-09-03 19:37:40 +00:00
0d79490d47 yarn upgrade --latest 2023-09-03 19:31:49 +00:00
5d8ca2fd2e devcontainer settings 2023-09-03 19:31:32 +00:00
f0bac2d168 Merge branch 'develop' into feature/drawrects 2023-09-03 16:45:01 +00:00
5a07ca5dfd python "black" formatting 2023-09-03 16:44:18 +00:00
48f58a7953 remove some deprecated values 2023-09-03 16:37:43 +00:00
73bfc16bfd minor hiccups 2023-09-03 16:23:01 +00:00
d654fb8f6b Merge branch 'develop' into feature/drawrects 2023-09-03 16:03:11 +00:00
fb20275775 fix debugger warning 2023-09-03 16:02:00 +00:00
c47ecdab18 vscode extensions 2023-09-03 15:57:00 +00:00
61ad59e90a API: poetry upgrade 2023-09-03 15:56:00 +00:00
b0766933b0 Python 3.11 + vscode-python3 2023-09-03 15:56:00 +00:00
aa282c122c yarn upgrade 2023-03-14 23:28:05 +00:00
4326bfb8b8 Merge branch 'develop' into feature/drawrects 2023-03-14 23:26:46 +00:00
dc970deb6c yarn upgrade 2023-03-14 23:19:18 +00:00
b9594ade83 various minor improvements 2023-02-18 00:43:07 +00:00
2551b0480b PreviewDoor combining SVGRect and foreignObject 2023-02-17 19:00:27 +00:00
bb89d49f0d Merge branch 'develop' into feature/drawrects 2023-02-15 23:52:01 +00:00
5903e43e01 yarn upgrade 2023-02-15 23:50:32 +00:00
d13d5b1525 yarn upgrade 2023-02-15 23:46:39 +00:00
0a18bdd2a0 Simple SVGRectText as foreignObject 2023-02-12 01:20:54 +00:00
67c371b483 DoorChooser keyboard input method 2023-02-02 23:16:44 +00:00
1ab02e6905 parent_aspect_ratio suspicious value handling 2023-02-02 22:55:30 +00:00
ef5281e57b DoorChooser usable but bad 2023-02-02 18:06:55 +00:00
d0756fda92 fixes for (still crude) DoorChooser 2023-02-02 15:29:26 +00:00
1695e8651c RectangleCanvas.rectangles is a prop, don't init 2023-02-02 15:29:04 +00:00
6628efe957 Door interface 2023-02-02 15:28:30 +00:00
6d7646daf7 DoorPlacer with "Door" logic 2023-02-02 14:12:44 +00:00
0372ad6c4d "equals" methods for Vector2D, Rectangle 2023-02-02 14:10:45 +00:00
8efe6ee17c Merge branch 'feature/drawrects' into feature/drawrects+door 2023-02-02 12:51:02 +00:00
c74c63be54 BulmaBreadcrumbs v-model 2023-02-02 12:50:12 +00:00
78f2b981e3 WIP: Door class (breaking) 2023-02-01 16:53:24 +00:00
be78671ffe BulmaBreadcrumb interface Step 2023-02-01 09:45:01 +00:00
3af743a6e6 BulmaBreadcrumb component 2023-01-31 22:31:27 +00:00
f87b25f730 add Rectangle.middle, update test suite 2023-01-31 22:19:30 +00:00
9ee55fc5af CanvasState implementation 2023-01-31 14:21:06 +00:00
f3a73f8ad0 RectPad -> RectangleCanvas 2023-01-27 00:23:41 +00:00
ba066c5779 refactor: renamings 2023-01-25 11:39:58 +00:00
01e88c6059 pointer cursor while choosing 2023-01-25 09:56:05 +00:00
75306742c5 more anonymous default exports 2023-01-24 23:48:18 +00:00
31ee11fbf2 Merge branch 'develop' into feature/drawrects 2023-01-24 23:47:22 +00:00
cba03dfeda more backtick strings 2023-01-24 23:45:26 +00:00
f12bcf01b7 Merge branch 'develop' into feature/drawrects 2023-01-24 23:44:13 +00:00
fbf7f466cb anonymous default exports 2023-01-24 23:42:50 +00:00
2882f35b06 backtick strings 2023-01-24 23:42:50 +00:00
9431673eee account for SVG aspect ratio 2023-01-24 23:11:49 +00:00
a977b9b11c svg version 2023-01-24 23:11:15 +00:00
a6c5a5469a allow Vector2D scaling 2023-01-24 23:11:01 +00:00
42ce94f313 Merge branch 'develop' into feature/drawrects 2023-01-24 11:40:48 +00:00
bf5f4ffbb3 vue devserver force localhost 2023-01-24 11:40:01 +00:00
a55d1a25ba RectText component 2023-01-24 11:35:45 +00:00
049763a411 yarn upgrade 2023-01-24 00:21:33 +00:00
6ee2a80a23 Merge branch 'develop' into feature/drawrects 2023-01-24 00:19:12 +00:00
f1b3cbeeab yarn upgrade 2023-01-24 00:18:54 +00:00
8048b68725 Basic DoorChooser 2023-01-24 00:14:50 +00:00
709e0c81e4 ThouCanvas style 2023-01-23 23:49:29 +00:00
9d9e3faa02 DoorPlacer v-model 2023-01-23 23:37:23 +00:00
5752d2b814 test verbosity 2023-01-23 22:38:57 +00:00
dde8f172dc coordinate transformation into ThouCanvas 2023-01-23 22:33:39 +00:00
1ca9ca6577 "describe" params 2023-01-23 15:07:40 +00:00
33b8a66b23 remove beed for "normalize" 2023-01-23 14:38:49 +00:00
47d7815067 add "mocha" unit testing 2023-01-21 02:59:46 +00:00
641492339a trim "get classes" 2023-01-20 23:48:33 +00:00
a715e761a9 use change_step 2023-01-20 00:51:11 +00:00
1e5fae0caa "_rectangles" -> "rectangles_array" 2023-01-20 00:46:17 +00:00
c0bc9aee5d steps with icons 2023-01-20 00:42:47 +00:00
63f5b3fe19 FontAwesomePlugin 2023-01-20 00:41:15 +00:00
e8dd32c71f use "breadcrumbs" 2023-01-19 19:35:20 +00:00
22d58f94b6 DoorPlacer component 2023-01-19 17:59:18 +00:00
2db63db8db tabs function 2023-01-19 17:54:46 +00:00
3bf8872a33 z-index problem 2023-01-19 00:44:03 +00:00
9647132569 "rectangles" property 2023-01-19 00:27:38 +00:00
93f31cd2b8 only remove rect while not editing 2023-01-19 00:27:21 +00:00
d387aceedd bulma-ify DoorMapEditor 2023-01-19 00:22:01 +00:00
95c4c2e5ff move rectpad size 2023-01-18 22:45:12 +00:00
26564a5cd3 Merge branch 'develop' into feature/drawrects 2023-01-18 00:50:05 +00:00
dcdb28b360 add git-lfs to devcontainer 2023-01-18 00:49:52 +00:00
714e31b1d7 Merge branch 'lfs' into drawrects 2023-01-18 00:26:48 +00:00
f7f5646699 lfs track assets 2023-01-18 00:24:02 +00:00
2f776566f3 png -> jpg 2023-01-18 00:20:15 +00:00
8689ba8473 more reasonable minimum area 2023-01-18 00:04:43 +00:00
6ffc5f076c revert to single "focused" prop 2023-01-17 23:58:46 +00:00
740eb574c6 middle click delete 2023-01-17 23:56:07 +00:00
b81446e99d readability 2023-01-17 23:50:25 +00:00
c1da30cc3e drag_rect implementation 2023-01-17 23:42:07 +00:00
7160cfeff7 drag rectangles 2023-01-17 23:34:42 +00:00
7650fb16a5 mouse handler names 2023-01-17 22:42:21 +00:00
b4492dcb73 "focused" -> "highlighted, selected" 2023-01-17 22:33:11 +00:00
cc1ab7be90 don't restart drawing after blurred pointerup 2023-01-17 22:15:12 +00:00
10751cb798 CalendarImage -> DoorMapEditor 2023-01-17 18:25:56 +00:00
eae3eed29b minor fixes 2023-01-17 18:03:45 +00:00
b7f7b05e73 Merge branch 'develop' into drawrects 2023-01-17 14:28:22 +00:00
4fe8bf1847 remove ": void" 2023-01-17 14:28:11 +00:00
96de19fbbd show drawn rectangles 2023-01-17 14:26:39 +00:00
9fa1498af3 "mouse" to "pointer" events 2023-01-17 14:09:13 +00:00
d16abe2658 cursor: crosshair 2023-01-17 00:57:47 +00:00
f3f8522242 "rectangles" library 2023-01-17 00:51:52 +00:00
e1bd10980c "preview" rectangle 2023-01-16 23:40:25 +00:00
f0db5e57ec drawrects stub 2023-01-16 00:19:16 +00:00
penner
2052efedd9 cleanup 2022-12-22 00:16:42 +00:00
penner
cb7c785ef4 endpoint slashes 2022-12-22 00:15:59 +00:00
penner
e53619ebcf ESC login 2022-12-21 23:51:25 +00:00
penner
82ebd211f6 Simple image modal 2022-12-21 23:51:01 +00:00
penner
fcf9cd458e visible_days 2022-12-21 23:48:54 +00:00
penner
0ce4e123d9 Umbenennung get_visible_days 2022-12-21 23:38:33 +00:00
penner
f81933946c yarn upgrade 2022-12-14 02:51:17 +00:00
penner
b823aeaa69 get Number 2022-12-14 02:48:35 +00:00
penner
8269d8c976 Del Leere Tags 2022-12-14 02:40:51 +00:00
penner
a7666c741d Anmeldungsfeld 2022-12-14 02:39:32 +00:00
penner
2b05e468e6 api get str 2022-12-08 23:21:52 +00:00
e6f1f77ec3 Font via DAV 2022-12-08 21:20:32 +00:00
a3583396ec add flake8 linter 2022-11-29 22:51:37 +00:00
09672cda69 class Random 2022-11-23 04:36:40 +01:00
de4881ced3 Merge branch 'feature/axios' into develop 2022-11-22 22:28:22 +00:00
a7d9b4a3b5 typo 2022-11-22 22:27:57 +00:00
9f81895600 Config Abschnitt "Puzzle" 2022-11-18 01:39:05 +00:00
e9232fdcac remove legacy 2022-11-17 22:28:22 +00:00
cae145ea6a axios into plugin 2022-11-16 01:37:52 +00:00
0673d239d9 image URL 2022-11-16 00:21:14 +00:00
535239c1ed get_date endpoint 2022-11-15 23:53:30 +00:00
66dbf9c9d8 Berechtigungen 2022-11-15 23:43:13 +00:00
2802b04657 Image-Methoden nach _misc 2022-11-15 22:58:04 +00:00
fb9cfc0390 NEIN 2022-11-15 22:56:39 +00:00
742023d191 HTTPBasic Auth 2022-11-15 22:17:32 +00:00
8a279a2a11 Unterstützung für Manuelle Bilder 2022-11-04 20:00:43 +00:00
65922626bb config.py 2022-11-04 18:49:31 +00:00
78d9f7d194 ImageModal wrapper 2022-11-04 01:11:20 +00:00
9da577c613 single ImageModal
Co-authored-by: Penner4242 <Penner4242@users.noreply.github.com>
2022-11-03 23:01:28 +00:00
ffd1133b24 Revert "VSCode: Vetur -> Volar (vue3)"
This reverts commit f70a05f77eac1f08d1d8eff87cca309aa86be6a5.
2022-11-03 17:56:08 +00:00
231dfe6f9e VSCode: Vetur -> Volar (vue3) 2022-11-03 17:53:06 +00:00
689013e202 VSCode Sass support 2022-11-03 17:49:25 +00:00
f707494058 main Stylesheet 2022-11-03 17:30:29 +00:00
c1ea31f874 Advent22 typescript plugin 2022-11-03 15:04:57 +00:00
f585b5f90c ImageModal Bilder 2022-10-30 03:10:07 +00:00
cf318212ba ImageModal v-model 2022-10-30 03:04:25 +00:00
b6deb22cc8 ImageModal 2022-10-30 02:35:39 +00:00
382ed35a48 Irgendwas mit 24 Türchen 2022-10-30 01:27:46 +00:00
858062b9ee Crude CalendarBild 2022-10-28 00:28:03 +00:00
2c584cff8b Vue Projekt 2022-10-28 00:14:20 +00:00
4567ab12f8 app middlewares 2022-10-27 23:48:02 +00:00
cf812f23cd DAV Caching 2022-10-27 23:32:45 +00:00
c0302fbe3c doc 2022-10-27 22:32:20 +00:00
a01e8c8a8a vars eliminieren 2022-10-14 23:38:37 +00:00
12c46acf69 AdventImage.hide_text 2022-10-14 23:35:17 +00:00
b93f95020d correct text file decoding 2022-10-14 23:29:05 +00:00
4ea2b37362 remove static "loesungswort" 2022-10-14 23:21:14 +00:00
3bee806262 doc 2022-10-14 23:20:35 +00:00
12ed54f925 set_length own function 2022-10-14 23:03:36 +00:00
f0b314fd7c general router (empty) 2022-10-14 22:47:48 +00:00
71482983bf shuffle funktion 2022-10-14 22:47:16 +00:00
7fc8f8bbc5 dav_* naming 2022-10-14 22:15:20 +00:00
752c449831 AdventImage module 2022-10-14 22:09:23 +00:00
f7f3fec891 dav_common module 2022-10-14 21:42:05 +00:00
0c51199215 random picture 2022-10-11 00:29:27 +00:00
3c925726d3 WebDAV interface 2022-10-10 23:46:27 +00:00
19336c542c random buchstaben position 2022-10-10 22:59:52 +00:00
444c16b0b4 typo 2022-10-10 20:25:11 +00:00
d13006ce95 Lena-Bilder 2022-10-10 20:24:37 +00:00
0e792d4304 Dokumentation und Co 2022-10-10 20:22:56 +00:00
25019c8ccc split functions 2022-10-10 18:44:01 +00:00
b6e9ade1b2 pythonium 2022-10-10 02:15:47 +00:00
2e6daa9da3 buchstaben verstecken 2022-10-10 02:12:24 +00:00
f49f12c981 router "days" mit bisschen Bildverarbeitung 2022-10-10 00:09:09 +00:00
25e7aa9f86 comment 2022-10-09 00:47:30 +00:00
1431101fcd mehr spacken 2022-10-09 00:40:20 +00:00
ac2ab19a76 Base router and some Gelöt :) 2022-10-09 00:18:15 +00:00
c278bed0b7 Leere API 2022-10-08 23:36:16 +00:00
69f41cc0d2 empty project 2022-10-08 00:18:16 +00:00
0f5fa3e1c7 api: basic poetry 2022-10-08 01:50:20 +02:00
85 changed files with 13985 additions and 0 deletions

27
.dockerignore Normal file
View 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
View 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
View 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)

View 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
View file

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

View file

@ -152,3 +152,4 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
api.conf

22
api/.vscode/launch.json vendored Normal file
View 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
View 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
View 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=["*"],
)

View file

View 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,
)

View 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))

View 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))

View file

View 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

View 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))

View 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,
)

View 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}

View 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()

View 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
View 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()

View 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)

View 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

View 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

View 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

File diff suppressed because it is too large Load diff

33
api/pyproject.toml Normal file
View 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"]

View 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
View 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
View file

@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

View 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"
}

35
ui/.eslintrc.js Normal file
View file

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

23
ui/.gitignore vendored Normal file
View 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
View file

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

15
ui/.vscode/launch.json vendored Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
{
"presets": [
"@vue/cli-plugin-babel/preset"
]
}

45
ui/package.json Normal file
View file

@ -0,0 +1,45 @@
{
"name": "advent22_ui",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8080",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit",
"test:unit-watch": "vue-cli-service test:unit --watch",
"lint": "vue-cli-service lint"
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/vue-fontawesome": "^3.0.8",
"@types/chai": "^4.3.17",
"@types/luxon": "^3.4.2",
"@types/mocha": "^10.0.7",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0",
"@vue/cli-plugin-unit-mocha": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.6",
"animate.css": "^4.1.1",
"axios": "^1.7.3",
"bulma": "^1.0.2",
"bulma-toast": "2.4.3",
"chai": "^5.1.1",
"core-js": "^3.38.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.27.0",
"luxon": "^3.5.0",
"pinia": "^2.2.1",
"sass": "^1.77.8",
"sass-loader": "^16.0.0",
"typescript": "~5.5.4",
"vue": "^3.4.37",
"vue-class-component": "^8.0.0-0"
},
"dependencies": {}
}

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

32
ui/public/index.html Normal file
View 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>

78
ui/src/App.vue Normal file
View file

@ -0,0 +1,78 @@
<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">
<progress
v-if="store.background_image === 'loading'"
class="progress is-primary"
max="100"
/>
<div
v-else-if="store.background_image === 'error'"
class="notification is-danger"
>
Hintergrundbild konnte nicht geladen werden
</div>
<div v-else class="container">
<AdminView v-if="store.is_admin" />
<UserView v-else />
</div>
</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 "./lib/store";
import AdminView from "./components/admin/AdminView.vue";
import AdminButton from "./components/AdminButton.vue";
import TouchButton from "./components/TouchButton.vue";
import UserView from "./components/UserView.vue";
@Options({
components: {
AdminView,
AdminButton,
TouchButton,
UserView,
},
})
export default class extends Vue {
public readonly store = advent22Store();
}
</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

Binary file not shown.

14
ui/src/bulma-scheme.scss Normal file
View file

@ -0,0 +1,14 @@
@charset "utf-8";
//=====================
// custom color scheme
//=====================
$colors: (
"primary": #945DE1,
"link": #64B4BD,
"info": #8C4E80,
"success": #7E8E2B,
"warning": #F6CA6B,
"danger": #C5443B,
);

View file

@ -0,0 +1,57 @@
<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/model";
import { advent22Store } from "@/lib/store";
import { Options, Vue } from "vue-class-component";
import { APIError } from "@/lib/api_error";
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((error) => APIError.alert(error))
.finally(() => (this.is_busy = false));
}
public on_cancel() {
this.modal_visible = false;
this.is_busy = false;
}
}
</script>

View file

@ -0,0 +1,131 @@
<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="_ensure_loaded(store.background_image).data_url" />
<ThouCanvas>
<CalendarDoor
v-for="(door, index) in doors"
:key="`door-${index}`"
:door="door"
:visible="store.is_touch_device"
:title="_name_door(door.day)"
@click="door_click(door.day)"
style="cursor: pointer"
/>
</ThouCanvas>
</div>
</figure>
</template>
<script lang="ts">
import { API } from "@/lib/api";
import { APIError } from "@/lib/api_error";
import { ensure_loaded, Loading, name_door } from "@/lib/helpers";
import { ImageData } from "@/lib/model";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/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?: typeof BulmaToast;
private toast_timeout?: number;
public modal_handle(modal: MultiModal) {
this.multi_modal = modal;
}
public toast_handle(toast: typeof 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 async 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();
try {
const day_image = await API.request<ImageData>(`user/image_${day}`);
this.multi_modal!.show_image(day_image.data_url, name_door(day));
} catch (error) {
APIError.alert(error);
this.multi_modal!.hide();
}
}
public beforeUnmount(): void {
this.toast?.hide();
}
public _ensure_loaded<T>(o: Loading<T>): T {
return ensure_loaded(o);
}
public _name_door(day: number): string {
return name_door(day);
}
}
</script>

View 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>

View 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>

View 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>

View file

@ -0,0 +1,28 @@
<template>
<span>Eingabemodus:&nbsp;</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 "@/lib/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>

View file

@ -0,0 +1,38 @@
<template>
<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>
<script lang="ts">
import { advent22Store } from "@/lib/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>

View 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>

View file

@ -0,0 +1,118 @@
<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 { API } from "@/lib/api";
import { name_door, objForEach } from "@/lib/helpers";
import { ImageData, NumStrDict } from "@/lib/model";
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([
API.request<NumStrDict>("admin/day_parts"),
API.request<NumStrDict>("admin/day_image_names"),
])
.then(([day_parts, day_image_names]) => {
const _ensure_day_in_data = (day: number) => {
if (!(day in this.day_data)) {
this.day_data[day] = { part: "", image_name: "" };
}
};
objForEach(day_parts, (day, part) => {
_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 async door_click(day: number) {
if (this.multi_modal === undefined) return;
this.multi_modal.show_progress();
try {
const day_image = await API.request<ImageData>(`user/image_${day}`);
this.multi_modal!.show_image(day_image.data_url, name_door(day));
} catch (error) {
this.multi_modal!.hide();
}
}
}
</script>

View file

@ -0,0 +1,292 @@
<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/model";
import { advent22Store } from "@/lib/store";
import { DateTime } from "luxon";
import { Options, Vue } from "vue-class-component";
import { API } from "@/lib/api";
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(),
API.request<AdminConfigModel>("admin/config_model"),
API.request<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 {
API.request<Credentials>("admin/dav_credentials")
.then((creds) => (this.dav_credentials = creds))
.catch(() => {});
}
public load_ui_credentials(): void {
API.request<Credentials>("admin/ui_credentials")
.then((creds) => (this.ui_credentials = creds))
.catch(() => {});
}
}
</script>
<style scoped>
dd {
overflow-x: auto;
}
</style>

View file

@ -0,0 +1,196 @@
<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 { Step } from "@/lib/helpers";
import { DoorSaved } from "@/lib/model";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store";
import { Options, Vue } from "vue-class-component";
import { API } from "@/lib/api";
import { APIError } from "@/lib/api_error";
import { toast } from "bulma-toast";
import Calendar from "../Calendar.vue";
import BulmaBreadcrumbs from "../bulma/Breadcrumbs.vue";
import BulmaButton from "../bulma/Button.vue";
import BulmaDrawer from "../bulma/Drawer.vue";
import DoorChooser from "../editor/DoorChooser.vue";
import DoorPlacer from "../editor/DoorPlacer.vue";
@Options({
components: {
BulmaBreadcrumbs,
BulmaButton,
BulmaDrawer,
DoorPlacer,
DoorChooser,
Calendar,
},
})
export default class extends Vue {
public readonly steps: Step[] = [
{ label: "Platzieren", icon: "fa-solid fa-crosshairs" },
{ label: "Ordnen", icon: "fa-solid fa-list-ol" },
{ label: "Vorschau", icon: "fa-solid fa-magnifying-glass" },
];
public current_step = 0;
public doors: Door[] = [];
private readonly store = advent22Store();
public loading_doors = false;
public saving_doors = false;
private load_doors(): Promise<void> {
return new Promise<void>((resolve, reject) => {
API.request<DoorSaved[]>("admin/doors")
.then((data) => {
this.doors.length = 0;
for (const value of data) {
this.doors.push(Door.load(value));
}
resolve();
})
.catch((error) => {
APIError.alert(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());
}
API.request<void>({ endpoint: "admin/doors", method: "PUT", data: data })
.then(resolve)
.catch((error) => {
APIError.alert(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>

View file

@ -0,0 +1,39 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<nav class="breadcrumb has-succeeds-separator">
<ul>
<li
v-for="(step, index) in steps"
:key="index"
:class="modelValue === index ? 'is-active' : ''"
@click.left="change_step(index)"
>
<a>
<span class="icon is-small">
<FontAwesomeIcon :icon="step.icon" />
</span>
<span>{{ step.label }}</span>
</a>
</li>
</ul>
</nav>
</template>
<script setup lang="ts">
import { Step } from "@/lib/helpers";
const props = defineProps<{
steps: Step[];
modelValue: number;
}>();
const emit = defineEmits<{
"update:modelValue": [number];
}>();
function change_step(next_step: number) {
if (next_step === props.modelValue) return;
emit("update:modelValue", next_step);
}
</script>

View file

@ -0,0 +1,28 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<button class="button">
<slot name="default">
<span v-if="icon !== undefined" class="icon">
<FontAwesomeIcon
v-if="icon !== undefined"
:icon="icon"
:beat-fade="busy"
/>
</span>
</slot>
<span v-if="text !== undefined">{{ text }}</span>
</button>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
icon?: string | string[];
text?: string;
busy?: boolean;
}>(),
{
busy: false,
},
);
</script>

View file

@ -0,0 +1,89 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<div class="card">
<header class="card-header is-unselectable" style="cursor: pointer">
<p class="card-header-title" @click="toggle">{{ header }}</p>
<p v-if="refreshable" class="card-header-icon px-0">
<BulmaButton class="is-small is-primary" @click="refresh">
<FontAwesomeIcon
:icon="['fas', 'arrows-rotate']"
:spin="is_open && state === 'loading'"
/>
</BulmaButton>
</p>
<button class="card-header-icon" @click="toggle">
<span class="icon">
<FontAwesomeIcon
:icon="['fas', is_open ? 'angle-down' : 'angle-right']"
/>
</span>
</button>
</header>
<template v-if="is_open">
<div v-if="state === 'loading'" class="card-content">
<progress class="progress is-primary" />
</div>
<div
v-else-if="state === 'failed'"
class="card-content has-text-danger has-text-centered"
>
<span class="icon is-large">
<FontAwesomeIcon :icon="['fas', 'ban']" size="3x" />
</span>
</div>
<slot v-else name="default" />
</template>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import BulmaButton from "./Button.vue";
withDefaults(
defineProps<{
header: string;
refreshable?: boolean;
}>(),
{ refreshable: false },
);
const emit = defineEmits<{
open: [
{
ready(): void;
fail(): void;
},
];
}>();
const is_open = ref(false);
const state = ref<"loading" | "ready" | "failed">("loading");
function toggle() {
is_open.value = !is_open.value;
if (is_open.value) {
state.value = "loading";
emit("open", {
ready: () => (state.value = "ready"),
fail: () => (state.value = "failed"),
});
}
}
function refresh() {
is_open.value = false;
toggle();
}
</script>
<style scoped>
div.card:not(:last-child) {
margin-bottom: 1.5rem;
}
</style>

View file

@ -0,0 +1,47 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<slot v-if="state === 'show'" name="default" />
<span v-else>***</span>
<BulmaButton
:class="`is-small is-${button_class} ml-2`"
:icon="['fas', `${button_icon}`]"
:busy="state === 'click'"
@click="on_click"
/>
</template>
<script setup lang="ts">
import { ref } from "vue";
import BulmaButton from "./Button.vue";
const emit = defineEmits<{
load: [];
}>();
const state = ref<"start" | "click" | "show">("start");
const button_class = ref<"primary" | "warning" | "danger">("primary");
const button_icon = ref<"eye-slash" | "eye">("eye-slash");
function on_click(): void {
switch (state.value) {
case "show":
state.value = "start";
button_class.value = "primary";
button_icon.value = "eye-slash";
break;
case "start":
state.value = "click";
button_class.value = "warning";
button_icon.value = "eye-slash";
break;
case "click":
state.value = "show";
button_class.value = "danger";
button_icon.value = "eye";
emit("load");
break;
}
}
</script>

View file

@ -0,0 +1,49 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<div style="display: none">
<div v-bind="$attrs" ref="message">
<slot name="default" />
</div>
</div>
</template>
<script setup lang="ts">
import * as bulmaToast from "bulma-toast";
import { onMounted, ref } from "vue";
const emit = defineEmits<{
handle: [
{
show(options: bulmaToast.Options): void;
hide(): void;
},
];
}>();
const message = ref<HTMLDivElement | null>(null);
onMounted(() =>
emit("handle", {
show(options: bulmaToast.Options = {}) {
if (!(message.value instanceof HTMLElement)) return;
bulmaToast.toast({
...options,
single: true,
message: message.value,
});
},
hide() {
if (!(message.value instanceof HTMLElement)) return;
const toast_div = message.value.parentElement;
if (!(toast_div instanceof HTMLDivElement)) return;
const dbutton = toast_div.querySelector("button.delete");
if (!(dbutton instanceof HTMLButtonElement)) return;
dbutton.click();
},
}),
);
</script>

View 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/rects/door";
import { advent22Store } from "@/lib/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>

View file

@ -0,0 +1,76 @@
<template>
<foreignObject
:x="Math.round(get_bg_aspect_ratio() * rectangle.left)"
:y="rectangle.top"
:width="Math.round(get_bg_aspect_ratio() * rectangle.width)"
:height="rectangle.height"
:style="`transform: scaleX(${1 / get_bg_aspect_ratio()})`"
>
<div
xmlns="http://www.w3.org/1999/xhtml"
:class="`px-2 is-flex is-align-items-center is-justify-content-center is-size-2 has-text-weight-bold ${variant} ${
visible ? 'visible' : ''
}`"
style="height: inherit"
v-bind="$attrs"
>
<slot name="default" />
</div>
</foreignObject>
</template>
<script setup lang="ts">
import { loading_success } from "@/lib/helpers";
import { Rectangle } from "@/lib/rects/rectangle";
import { advent22Store } from "@/lib/store";
const store = advent22Store();
type BulmaVariant =
| "primary"
| "link"
| "info"
| "success"
| "warning"
| "danger";
withDefaults(
defineProps<{
variant: BulmaVariant;
visible?: boolean;
rectangle: Rectangle;
}>(),
{
visible: true,
},
);
function get_bg_aspect_ratio(): number {
if (!loading_success(store.background_image)) return 1;
return store.background_image.height / store.background_image.width;
}
</script>
<style lang="scss" scoped>
@use "@/bulma-scheme" as scheme;
foreignObject > div {
&:not(.visible, :hover):deep() > * {
display: none;
}
&.visible,
&:hover {
border-width: 2px;
border-style: solid;
@each $name, $color in scheme.$colors {
&.#{$name} {
background-color: rgba($color, 0.3);
border-color: rgba($color, 0.9);
}
}
}
}
</style>

View file

@ -0,0 +1,62 @@
<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 setup lang="ts">
import { Vector2D } from "@/lib/rects/vector2d";
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),
);
}
type TCMouseEvent = [MouseEvent, Vector2D];
const emit = defineEmits<{
mousedown: TCMouseEvent;
mouseup: TCMouseEvent;
mousemove: TCMouseEvent;
click: TCMouseEvent;
dblclick: TCMouseEvent;
}>();
function transform_mouse_event(event: MouseEvent) {
const point = get_event_thous(event);
// mute a useless typescript error
const event_type = event.type as "mousedown";
emit(event_type, event, point);
}
</script>
<style scoped>
svg {
height: 100%;
width: 100%;
position: absolute;
left: 0;
top: 0;
z-index: auto;
}
</style>

View 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/rects/door";
import { Rectangle } from "@/lib/rects/rectangle";
import { Vector2D } from "@/lib/rects/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>

View file

@ -0,0 +1,52 @@
<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="_ensure_loaded(store.background_image).data_url" />
<ThouCanvas>
<PreviewDoor
v-for="(door, index) in doors"
:key="`door-${index}`"
:door="door"
/>
</ThouCanvas>
</figure>
</div>
</template>
<script lang="ts">
import { ensure_loaded, Loading } from "@/lib/helpers";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/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();
public _ensure_loaded<T>(o: Loading<T>): T {
return ensure_loaded(o);
}
}
</script>

View file

@ -0,0 +1,42 @@
<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="_ensure_loaded(store.background_image).data_url" />
<DoorCanvas :doors="doors" />
</figure>
</div>
</template>
<script lang="ts">
import { ensure_loaded, Loading } from "@/lib/helpers";
import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/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();
public _ensure_loaded<T>(o: Loading<T>): T {
return ensure_loaded(o);
}
}
</script>

View 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/rects/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>

6
ui/src/d.ts/shims-vue.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/* eslint-disable */
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

82
ui/src/lib/api.ts Normal file
View file

@ -0,0 +1,82 @@
import axios, {
AxiosBasicCredentials,
type AxiosRequestConfig,
type Method,
type RawAxiosRequestHeaders,
} from "axios";
import { APIError } from "./api_error";
interface Params {
endpoint: string;
method?: Method;
data?: unknown;
headers?: RawAxiosRequestHeaders;
config?: AxiosRequestConfig;
}
export class API {
private static get api_baseurl(): string {
// in production mode, return "proto://hostname/api"
if (process.env.NODE_ENV === "production") {
return `${window.location.protocol}//${window.location.host}/api`;
} else if (process.env.NODE_ENV !== "development") {
// not in prouction or development mode
console.warn("Unexpected NODE_ENV value");
}
// in development mode, return "proto://hostname:8000/api"
return `${window.location.protocol}//${window.location.hostname}:8000/api`;
}
private static readonly axios = axios.create({
timeout: 10e3,
baseURL: this.api_baseurl,
});
private static readonly storage_key = "advent22/credentials";
public static set creds(value: AxiosBasicCredentials | null) {
if (value === null) {
localStorage.removeItem(this.storage_key);
return;
}
localStorage.setItem(this.storage_key, JSON.stringify(value));
}
public static get creds(): AxiosBasicCredentials | undefined {
const auth_json = localStorage.getItem(this.storage_key);
if (auth_json !== null) return JSON.parse(auth_json);
}
private static get_axios_config({
endpoint,
method = "GET",
data,
headers = {},
config = {},
}: Params): AxiosRequestConfig {
return {
url: endpoint,
method: method,
data: data,
auth: this.creds,
headers: headers,
...config,
};
}
public static async request<T = string>(p: Params): Promise<T>;
public static async request<T = string>(p: string): Promise<T>;
public static async request<T = string>(p: Params | string): Promise<T> {
if (typeof p === "string") p = { endpoint: p };
try {
const response = await this.axios.request<T>(this.get_axios_config(p));
return response.data;
} catch (reason) {
console.error(`Failed to query ${p.endpoint}: ${reason}`);
throw new APIError(reason, p.endpoint);
}
}
}

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

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

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

@ -0,0 +1,44 @@
import { APIError } from "./api_error";
export function objForEach<T>(
obj: T,
f: (k: keyof T, v: T[keyof T]) => void,
): void {
for (const k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k)) {
f(k, obj[k]);
}
}
}
export type Loading<T> = T | "loading" | "error";
export function loading_success<T>(o: Loading<T>): o is T {
if (o === "loading") return false;
if (o === "error") return false;
return true;
}
export function ensure_loaded<T>(o: Loading<T>): T {
if (!loading_success(o)) throw "";
return o;
}
export function handle_error(error: unknown) {
if (error instanceof APIError) {
error.alert();
} else {
console.error(error);
}
}
export function name_door(day: number): string {
return `Türchen ${day}`;
}
export interface Step {
label: string;
icon: string | string[];
}

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

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

52
ui/src/lib/rects/door.ts Normal file
View file

@ -0,0 +1,52 @@
import { DoorSaved } from "../model";
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,
};
}
}

View 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),
);
}
}

View 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;
}
}

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

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

23
ui/src/main.scss Normal file
View file

@ -0,0 +1,23 @@
@charset "utf-8";
@use "sass:map";
//==============
// bulma
//==============
// custom color scheme
@use "bulma-scheme" as scheme;
@use "bulma/sass" with (
$primary: map.get(scheme.$colors, "primary"),
$link: map.get(scheme.$colors, "link"),
$info: map.get(scheme.$colors, "info"),
$success: map.get(scheme.$colors, "success"),
$warning: map.get(scheme.$colors, "warning"),
$danger: map.get(scheme.$colors, "danger")
);
//==============
// main imports
//==============
@import "animate.css/animate";

27
ui/src/main.ts Normal file
View file

@ -0,0 +1,27 @@
import { advent22Store } from "@/lib/store";
import { FontAwesomePlugin } from "@/plugins/fontawesome";
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(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" },
});

View 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);
/* import font awesome icon component */
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
export const FontAwesomePlugin: Plugin = {
install(app: App) {
app.component("font-awesome-icon", FontAwesomeIcon);
},
};

View file

@ -0,0 +1,90 @@
import { expect } from "chai";
import { Rectangle } from "@/lib/rects/rectangle";
import { Vector2D } from "@/lib/rects/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);
});
});

View file

@ -0,0 +1,41 @@
import { expect } from "chai";
import { Vector2D } from "@/lib/rects/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
View 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
View 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",
}),
],
},
});

7533
ui/yarn.lock Normal file

File diff suppressed because it is too large Load diff