Compare commits

...

3 commits

Author SHA1 Message Date
7cca8c4825 🔧 ui: minor changes to helpers
- rename `Like` -> `VueLike` (also `unwrap_like` -> `unwrap_vuelike`)
- rename `ensure_loaded` -> `unwrap_loading`
- make `unwrap_loading` throw `null` on error instead of `""`
2025-12-28 01:24:31 +00:00
eacd1da482 🔧 ui: BulmaDrawer state machine rework 2025-12-28 01:19:17 +00:00
d5c935ade7 🔧 ui: more use of async functions and try-catch-blocks
🚧 WIP
2025-12-27 14:01:03 +00:00
14 changed files with 141 additions and 162 deletions

View file

@ -30,7 +30,7 @@
<figure> <figure>
<div class="image is-unselectable"> <div class="image is-unselectable">
<img :src="ensure_loaded(store.background_image).data_url" /> <img :src="unwrap_loading(store.background_image).data_url" />
<ThouCanvas> <ThouCanvas>
<CalendarDoor <CalendarDoor
v-for="(door, index) in doors" v-for="(door, index) in doors"
@ -49,7 +49,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { API } from "@/lib/api"; import { API } from "@/lib/api";
import { APIError } from "@/lib/api_error"; import { APIError } from "@/lib/api_error";
import { ensure_loaded, Like, name_door } from "@/lib/helpers"; import { VueLike, name_door, unwrap_loading } from "@/lib/helpers";
import { ImageData } from "@/lib/model"; import { ImageData } from "@/lib/model";
import { Door } from "@/lib/rects/door"; import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store"; import { advent22Store } from "@/lib/store";
@ -62,7 +62,7 @@ import CalendarDoor from "./calendar/CalendarDoor.vue";
import ThouCanvas from "./calendar/ThouCanvas.vue"; import ThouCanvas from "./calendar/ThouCanvas.vue";
defineProps<{ defineProps<{
doors: Like<Door>[]; doors: VueLike<Door>[];
}>(); }>();
const store = advent22Store(); const store = advent22Store();

View file

@ -2,7 +2,7 @@
<ConfigView /> <ConfigView />
<CalendarAssistant /> <CalendarAssistant />
<DoorMapEditor /> <DoorMapEditor />
<BulmaDrawer header="Vorschau" @open="(ready) => ready()"> <BulmaDrawer header="Vorschau">
<UserView /> <UserView />
</BulmaDrawer> </BulmaDrawer>
</template> </template>

View file

@ -1,7 +1,7 @@
<template> <template>
<MultiModal @handle="on_modal_handle" /> <MultiModal @handle="on_modal_handle" />
<BulmaDrawer header="Kalender-Assistent" @open="on_open" refreshable> <BulmaDrawer header="Kalender-Assistent" :opening="on_open" refreshable>
<div class="card-content"> <div class="card-content">
<div class="content"> <div class="content">
<p>Hervorgehobenen Tagen wurde kein Buchstabe zugewiesen.</p> <p>Hervorgehobenen Tagen wurde kein Buchstabe zugewiesen.</p>
@ -37,7 +37,7 @@
:class="'tag is-' + (data.part === '' ? 'warning' : 'info')" :class="'tag is-' + (data.part === '' ? 'warning' : 'info')"
icon="fa-solid fa-door-open" icon="fa-solid fa-door-open"
:text="day.toString()" :text="day.toString()"
@click.left="door_click(day)" @click.left="door_click(Number(day))"
/> />
</div> </div>
</div> </div>
@ -55,12 +55,7 @@ import MultiModal, { HMultiModal } from "../MultiModal.vue";
import BulmaButton from "../bulma/Button.vue"; import BulmaButton from "../bulma/Button.vue";
import BulmaDrawer from "../bulma/Drawer.vue"; import BulmaDrawer from "../bulma/Drawer.vue";
const day_data = ref<{ const day_data = ref<Record<number, { part: string; image_name: string }>>({});
[day: number]: {
part: string;
image_name: string;
};
}>({});
let modal: HMultiModal | undefined; let modal: HMultiModal | undefined;
@ -68,12 +63,12 @@ function on_modal_handle(handle: HMultiModal) {
modal = handle; modal = handle;
} }
function on_open(ready: () => void, fail: () => void) { async function on_open() {
Promise.all([ const [day_parts, day_image_names] = await Promise.all([
API.request<NumStrDict>("admin/day_parts"), API.request<NumStrDict>("admin/day_parts"),
API.request<NumStrDict>("admin/day_image_names"), API.request<NumStrDict>("admin/day_image_names"),
]) ]);
.then(([day_parts, day_image_names]) => {
const _ensure_day_in_data = (day: number) => { const _ensure_day_in_data = (day: number) => {
if (!(day in day_data.value)) { if (!(day in day_data.value)) {
day_data.value[day] = { part: "", image_name: "" }; day_data.value[day] = { part: "", image_name: "" };
@ -89,10 +84,6 @@ function on_open(ready: () => void, fail: () => void) {
_ensure_day_in_data(day); _ensure_day_in_data(day);
day_data.value[day].image_name = image_name; day_data.value[day].image_name = image_name;
}); });
ready();
})
.catch(fail);
} }
async function door_click(day: number) { async function door_click(day: number) {
@ -102,7 +93,7 @@ async function door_click(day: number) {
try { try {
const day_image = await API.request<ImageData>(`user/image_${day}`); const day_image = await API.request<ImageData>(`user/image_${day}`);
modal.show_image(day_image.data_url, name_door(day)); modal.show_image(day_image.data_url, name_door(day));
} catch (error) { } catch {
modal.hide(); modal.hide();
} }
} }

View file

@ -1,5 +1,5 @@
<template> <template>
<BulmaDrawer header="Konfiguration" @open="on_open" refreshable> <BulmaDrawer header="Konfiguration" :opening="on_open" refreshable>
<div class="card-content"> <div class="card-content">
<div class="columns"> <div class="columns">
<div class="column is-one-third"> <div class="column is-one-third">
@ -247,33 +247,32 @@ function fmt_puzzle_date(name: keyof AdminConfigModel["puzzle"]): string {
return DateTime.fromISO(iso_date).toLocaleString(DateTime.DATE_SHORT); return DateTime.fromISO(iso_date).toLocaleString(DateTime.DATE_SHORT);
} }
function on_open(ready: () => void, fail: () => void): void { async function on_open() {
Promise.all([ const [store_update, new_admin_config_model, new_doors] = await Promise.all([
store.update(), store.update(),
API.request<AdminConfigModel>("admin/config_model"), API.request<AdminConfigModel>("admin/config_model"),
API.request<DoorSaved[]>("admin/doors"), API.request<DoorSaved[]>("admin/doors"),
]) ]);
.then(([store_update, new_admin_config_model, new_doors]) => {
store_update; // discard value
void store_update;
admin_config_model.value = new_admin_config_model; admin_config_model.value = new_admin_config_model;
doors.value = new_doors; doors.value = new_doors;
ready();
})
.catch(fail);
} }
function load_dav_credentials(): void { async function load_dav_credentials() {
API.request<Credentials>("admin/dav_credentials") try {
.then((creds) => (dav_credentials.value = creds)) dav_credentials.value = await API.request<Credentials>(
.catch(() => {}); "admin/dav_credentials",
);
} catch {}
} }
function load_ui_credentials(): void { async function load_ui_credentials() {
API.request<Credentials>("admin/ui_credentials") try {
.then((creds) => (ui_credentials.value = creds)) ui_credentials.value = await API.request<Credentials>(
.catch(() => {}); "admin/ui_credentials",
);
} catch {}
} }
</script> </script>

View file

@ -1,5 +1,5 @@
<template> <template>
<BulmaDrawer header="Türchen bearbeiten" @open="on_open"> <BulmaDrawer header="Türchen bearbeiten" :opening="load_doors">
<nav class="level is-mobile mb-0" style="overflow-x: auto"> <nav class="level is-mobile mb-0" style="overflow-x: auto">
<BulmaButton <BulmaButton
:disabled="current_step === 0" :disabled="current_step === 0"
@ -95,60 +95,54 @@ const current_step = ref(0);
const loading_doors = ref(false); const loading_doors = ref(false);
const saving_doors = ref(false); const saving_doors = ref(false);
function load_doors(): Promise<void> { async function load_doors() {
return new Promise<void>((resolve, reject) => { try {
API.request<DoorSaved[]>("admin/doors") const data = await API.request<DoorSaved[]>("admin/doors");
.then((data) => {
doors.value.length = 0;
doors.value.length = 0;
for (const value of data) { for (const value of data) {
doors.value.push(Door.load(value)); doors.value.push(Door.load(value));
} }
} catch (error) {
resolve();
})
.catch((error) => {
APIError.alert(error); APIError.alert(error);
reject(); throw null;
}); }
});
} }
function save_doors(): Promise<void> { async function save_doors() {
return new Promise<void>((resolve, reject) => { try {
const data: DoorSaved[] = []; const data: DoorSaved[] = [];
for (const door of doors.value) { for (const door of doors.value) {
data.push(door.save()); data.push(door.save());
} }
API.request<void>({ endpoint: "admin/doors", method: "PUT", data: data }) await API.request<void>({
.then(resolve) endpoint: "admin/doors",
.catch((error) => { method: "PUT",
data: data,
});
} catch (error) {
APIError.alert(error); APIError.alert(error);
reject(); throw null;
}); }
});
} }
function on_open(ready: () => void, fail: () => void): void { async function on_download() {
load_doors().then(ready).catch(fail);
}
function on_download() {
if (confirm("Aktuelle Änderungen verwerfen und Status vom Server laden?")) { if (confirm("Aktuelle Änderungen verwerfen und Status vom Server laden?")) {
loading_doors.value = true; loading_doors.value = true;
load_doors() try {
.then(() => load_doors();
toast({ toast({
message: "Erfolgreich!", message: "Erfolgreich!",
type: "is-success", type: "is-success",
duration: 2e3, duration: 2e3,
}), });
) } finally {
.catch(() => {}) loading_doors.value = false;
.finally(() => (loading_doors.value = false)); }
} }
} }
@ -159,24 +153,22 @@ function on_discard() {
} }
} }
function on_upload() { async function on_upload() {
if (confirm("Aktuelle Änderungen an den Server schicken?")) { if (confirm("Aktuelle Änderungen an den Server schicken?")) {
saving_doors.value = true; saving_doors.value = true;
save_doors() try {
.then(() => { save_doors();
load_doors() load_doors();
.then(() =>
toast({ toast({
message: "Erfolgreich!", message: "Erfolgreich!",
type: "is-success", type: "is-success",
duration: 2e3, duration: 2e3,
}), });
) } finally {
.catch(() => {}) saving_doors.value = false;
.finally(() => (saving_doors.value = false)); }
})
.catch(() => (saving_doors.value = false));
} }
} }
</script> </script>

View file

@ -4,11 +4,11 @@
<header class="card-header is-unselectable" style="cursor: pointer"> <header class="card-header is-unselectable" style="cursor: pointer">
<p class="card-header-title" @click="toggle">{{ header }}</p> <p class="card-header-title" @click="toggle">{{ header }}</p>
<p v-if="refreshable" class="card-header-icon px-0"> <p v-if="refreshable && is_open" class="card-header-icon px-0">
<BulmaButton class="is-small is-primary" @click="refresh"> <BulmaButton class="is-small is-primary" @click="load">
<FontAwesomeIcon <FontAwesomeIcon
:icon="['fas', 'arrows-rotate']" :icon="['fas', 'arrows-rotate']"
:spin="is_open && state === 'loading'" :spin="state === 'loading'"
/> />
</BulmaButton> </BulmaButton>
</p> </p>
@ -27,7 +27,7 @@
<progress class="progress is-primary" /> <progress class="progress is-primary" />
</div> </div>
<div <div
v-else-if="state === 'failed'" v-else-if="state === 'err'"
class="card-content has-text-danger has-text-centered" class="card-content has-text-danger has-text-centered"
> >
<span class="icon is-large"> <span class="icon is-large">
@ -40,42 +40,39 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { computed, ref } from "vue";
import BulmaButton from "./Button.vue"; import BulmaButton from "./Button.vue";
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
header: string; header: string;
opening?: () => Promise<void>;
refreshable?: boolean; refreshable?: boolean;
}>(), }>(),
{ refreshable: false }, { opening: async () => {}, refreshable: false },
); );
const emit = defineEmits<{ const state = ref<"closed" | "loading" | "ok" | "err">("closed");
(event: "open", ready: () => void, fail: () => void): void; const is_open = computed(() => state.value !== "closed");
}>();
const is_open = ref(false);
const state = ref<"loading" | "ready" | "failed">("loading");
function toggle() {
is_open.value = !is_open.value;
async function toggle() {
if (is_open.value) { if (is_open.value) {
state.value = "loading"; state.value = "closed";
} else {
emit( await load();
"open",
() => (state.value = "ready"),
() => (state.value = "failed"),
);
} }
} }
function refresh() { async function load() {
is_open.value = false; state.value = "loading";
toggle();
try {
await props.opening();
state.value = "ok";
} catch {
state.value = "err";
}
} }
</script> </script>

View file

@ -17,14 +17,14 @@
import { Door } from "@/lib/rects/door"; import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store"; import { advent22Store } from "@/lib/store";
import { Like } from "@/lib/helpers"; import { VueLike } from "@/lib/helpers";
import SVGRect from "./SVGRect.vue"; import SVGRect from "./SVGRect.vue";
const store = advent22Store(); const store = advent22Store();
withDefaults( withDefaults(
defineProps<{ defineProps<{
door: Like<Door>; door: VueLike<Door>;
force_visible?: boolean; force_visible?: boolean;
}>(), }>(),
{ {

View file

@ -20,7 +20,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Like, loading_success } from "@/lib/helpers"; import { VueLike, loading_success } from "@/lib/helpers";
import { Rectangle } from "@/lib/rects/rectangle"; import { Rectangle } from "@/lib/rects/rectangle";
import { advent22Store } from "@/lib/store"; import { advent22Store } from "@/lib/store";
import { computed } from "vue"; import { computed } from "vue";
@ -39,7 +39,7 @@ withDefaults(
defineProps<{ defineProps<{
variant: BulmaVariant; variant: BulmaVariant;
visible?: boolean; visible?: boolean;
rectangle: Like<Rectangle>; rectangle: VueLike<Rectangle>;
}>(), }>(),
{ {
visible: true, visible: true,

View file

@ -29,7 +29,7 @@ import { Rectangle } from "@/lib/rects/rectangle";
import { Vector2D } from "@/lib/rects/vector2d"; import { Vector2D } from "@/lib/rects/vector2d";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { Like } from "@/lib/helpers"; import { VueLike } from "@/lib/helpers";
import CalendarDoor from "../calendar/CalendarDoor.vue"; import CalendarDoor from "../calendar/CalendarDoor.vue";
import SVGRect from "../calendar/SVGRect.vue"; import SVGRect from "../calendar/SVGRect.vue";
import ThouCanvas from "../calendar/ThouCanvas.vue"; import ThouCanvas from "../calendar/ThouCanvas.vue";
@ -37,9 +37,9 @@ import ThouCanvas from "../calendar/ThouCanvas.vue";
type CanvasState = type CanvasState =
| { kind: "idle" } | { kind: "idle" }
| { kind: "drawing" } | { kind: "drawing" }
| { kind: "dragging"; door: Like<Door>; origin: Vector2D }; | { kind: "dragging"; door: VueLike<Door>; origin: Vector2D };
const model = defineModel<Like<Door>[]>({ required: true }); const model = defineModel<VueLike<Door>[]>({ required: true });
const MIN_RECT_AREA = 300; const MIN_RECT_AREA = 300;
const state = ref<CanvasState>({ kind: "idle" }); const state = ref<CanvasState>({ kind: "idle" });
@ -47,7 +47,7 @@ const preview = ref(new Rectangle());
const preview_visible = computed(() => state.value.kind !== "idle"); const preview_visible = computed(() => state.value.kind !== "idle");
function pop_door(point: Vector2D): Like<Door> | undefined { function pop_door(point: Vector2D): VueLike<Door> | undefined {
const idx = model.value.findIndex((rect) => rect.position.contains(point)); const idx = model.value.findIndex((rect) => rect.position.contains(point));
if (idx === -1) { if (idx === -1) {

View file

@ -11,7 +11,7 @@
</ul> </ul>
</div> </div>
<figure class="image is-unselectable"> <figure class="image is-unselectable">
<img :src="ensure_loaded(store.background_image).data_url" /> <img :src="unwrap_loading(store.background_image).data_url" />
<ThouCanvas> <ThouCanvas>
<PreviewDoor <PreviewDoor
v-for="(_, index) in model" v-for="(_, index) in model"
@ -24,13 +24,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ensure_loaded, Like } from "@/lib/helpers"; import { VueLike, unwrap_loading } from "@/lib/helpers";
import { Door } from "@/lib/rects/door"; import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store"; import { advent22Store } from "@/lib/store";
import ThouCanvas from "../calendar/ThouCanvas.vue"; import ThouCanvas from "../calendar/ThouCanvas.vue";
import PreviewDoor from "./PreviewDoor.vue"; import PreviewDoor from "./PreviewDoor.vue";
const model = defineModel<Like<Door>[]>({ required: true }); const model = defineModel<VueLike<Door>[]>({ required: true });
const store = advent22Store(); const store = advent22Store();
</script> </script>

View file

@ -9,19 +9,19 @@
</ul> </ul>
</div> </div>
<figure class="image is-unselectable"> <figure class="image is-unselectable">
<img :src="ensure_loaded(store.background_image).data_url" /> <img :src="unwrap_loading(store.background_image).data_url" />
<DoorCanvas v-model="model" /> <DoorCanvas v-model="model" />
</figure> </figure>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ensure_loaded, Like } from "@/lib/helpers"; import { VueLike, unwrap_loading } from "@/lib/helpers";
import { Door } from "@/lib/rects/door"; import { Door } from "@/lib/rects/door";
import { advent22Store } from "@/lib/store"; import { advent22Store } from "@/lib/store";
import DoorCanvas from "./DoorCanvas.vue"; import DoorCanvas from "./DoorCanvas.vue";
const model = defineModel<Like<Door>[]>({ required: true }); const model = defineModel<VueLike<Door>[]>({ required: true });
const store = advent22Store(); const store = advent22Store();
</script> </script>

View file

@ -26,10 +26,10 @@
import { Door } from "@/lib/rects/door"; import { Door } from "@/lib/rects/door";
import { ref, useTemplateRef } from "vue"; import { ref, useTemplateRef } from "vue";
import { Like, unwrap_like, wait_for } from "@/lib/helpers"; import { VueLike, unwrap_vuelike, wait_for } from "@/lib/helpers";
import SVGRect from "../calendar/SVGRect.vue"; import SVGRect from "../calendar/SVGRect.vue";
const model = defineModel<Like<Door>>({ required: true }); const model = defineModel<VueLike<Door>>({ required: true });
const day_input = useTemplateRef("day_input"); const day_input = useTemplateRef("day_input");
const day_str = ref(""); const day_str = ref("");
@ -46,7 +46,7 @@ function on_click(event: MouseEvent) {
} }
if (editing.value) { if (editing.value) {
unwrap_like(model.value).day = day_str.value; unwrap_vuelike(model.value).day = day_str.value;
} else { } else {
wait_for( wait_for(
() => day_input.value !== null, () => day_input.value !== null,
@ -63,7 +63,7 @@ function on_keydown(event: KeyboardEvent) {
} }
if (event.key === "Enter") { if (event.key === "Enter") {
unwrap_like(model.value).day = day_str.value; unwrap_vuelike(model.value).day = day_str.value;
toggle_editing(); toggle_editing();
} else if (event.key === "Delete") { } else if (event.key === "Delete") {
model.value.day = -1; model.value.day = -1;

View file

@ -12,9 +12,9 @@ export function objForEach<T>(
} }
} }
export type Like<T> = T | UnwrapRef<T>; export type VueLike<T> = T | UnwrapRef<T>;
export function unwrap_like<T>(value: Like<T>): T { export function unwrap_vuelike<T>(value: VueLike<T>): T {
return value as T; return value as T;
} }
@ -27,8 +27,8 @@ export function loading_success<T>(o: Loading<T>): o is T {
return true; return true;
} }
export function ensure_loaded<T>(o: Loading<T>): T { export function unwrap_loading<T>(o: Loading<T>): T {
if (!loading_success(o)) throw ""; if (!loading_success(o)) throw null;
return o; return o;
} }

View file

@ -1,4 +1,4 @@
import { Like, unwrap_like } from "../helpers"; import { VueLike, unwrap_vuelike } from "../helpers";
import { DoorSaved } from "../model"; import { DoorSaved } from "../model";
import { Rectangle } from "./rectangle"; import { Rectangle } from "./rectangle";
import { Vector2D } from "./vector2d"; import { Vector2D } from "./vector2d";
@ -9,11 +9,11 @@ export class Door {
private _day = Door.MIN_DAY; private _day = Door.MIN_DAY;
public position: Rectangle; public position: Rectangle;
constructor(position: Like<Rectangle>); constructor(position: VueLike<Rectangle>);
constructor(position: Like<Rectangle>, day: number); constructor(position: VueLike<Rectangle>, day: number);
constructor(position: Like<Rectangle>, day = Door.MIN_DAY) { constructor(position: VueLike<Rectangle>, day = Door.MIN_DAY) {
this.day = day; this.day = day;
this.position = unwrap_like(position); this.position = unwrap_vuelike(position);
} }
public get day(): number { public get day(): number {