Merge pull request '#18-public-calendar' (#19) from #18-public-calendar into main

Reviewed-on: Ehrenamt/member-administration-ui#19
This commit is contained in:
Julian Krauser 2024-12-12 14:04:28 +00:00
commit 94b9e7daff
19 changed files with 428 additions and 9 deletions

View file

@ -30,11 +30,15 @@ services:
container_name: ff_member_administration_ui
restart: unless-stopped
#environment:
# - SERVER_ADRESS=<backend_host> # wichtig: ohne https:// bzw http://
#volumes:
# - <volume|local path>/myfavicon.png:/app/public/favicon.png
# - <volume|local path>/mylogo.png:/app/public/logo.png
```
Wenn keine Server-Adresse angegeben wird, wird versucht das Backend unter der URL des Frontends zu erreichen. Dazu muss das Backend auf der gleichen URL wie das Frontend laufen. Zur Unterscheidung von Frontend und Backend bei gleicher URL müssen alle Anfragen mit dem PathPrefix `/api` an das Backend weitergeleitet werden.
Führen Sie dann den folgenden Befehl im Verzeichnis der compose-Datei aus, um den Container zu starten:
```sh

BIN
public/calendar.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

View file

@ -5,13 +5,17 @@
>
<div class="w-full flex flex-row gap-2 h-full align-middle">
<TopLevelLink
v-if="routeName.includes('admin-')"
v-if="routeName == 'admin' || routeName.includes('admin-')"
v-for="item in topLevel"
:key="item.key"
:link="item"
:disableSubLink="true"
/>
<TopLevelLink v-else :link="{ key: 'club', title: 'Zur Verwaltung', levelDefault: '' }" :disableSubLink="true" />
<TopLevelLink
v-else-if="routeName == 'account' || routeName.includes('account-')"
:link="{ key: 'club', title: 'Zur Verwaltung', levelDefault: '' }"
:disableSubLink="true"
/>
</div>
</footer>
</template>

View file

@ -6,9 +6,14 @@
</RouterLink>
<div class="flex flex-row gap-2 items-center">
<div v-if="authCheck" class="hidden md:flex flex-row gap-2 h-full align-middle">
<TopLevelLink v-if="routeName.includes('admin-')" v-for="item in topLevel" :key="item.key" :link="item" />
<TopLevelLink
v-else-if="routeName.includes('account-')"
v-if="routeName == 'admin' || routeName.includes('admin-')"
v-for="item in topLevel"
:key="item.key"
:link="item"
/>
<TopLevelLink
v-else-if="routeName == 'account' || routeName.includes('account-')"
:link="{ key: 'club', title: 'Zur Verwaltung', levelDefault: '' }"
:disable-sub-link="true"
/>

View file

@ -0,0 +1,138 @@
<template>
<div class="relative w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Kalenderlink</p>
</div>
<br />
<div class="flex flex-col gap-2">
<div>
<Listbox v-model="selectedTypes" name="type" multiple>
<ListboxLabel>Typen zur Anzeige auswählen</ListboxLabel>
<div class="relative mt-1">
<ListboxButton
class="rounded-md shadow-sm relative block w-full px-3 py-2 border border-gray-300 focus:border-primary placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-0 focus:z-10 sm:text-sm resize-none"
>
<span class="block truncate w-full text-start">
{{
selectedTypes.length != 0
? selectedTypes?.map((t) => t.type).join(", ")
: "Standard-Typen werden ausgeliefert"
}}
</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute mt-1 max-h-60 z-20 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm h-32 overflow-y-auto"
>
<ListboxOption v-if="calendarTypes.length == 0" disabled as="template">
<li :class="['relative cursor-default select-none py-2 pl-10 pr-4']">
<span :class="['font-normal', 'block truncate']">keine Auswahl vorhanden</span>
</li>
</ListboxOption>
<ListboxOption
v-slot="{ active, selected }"
v-for="type in calendarTypes"
:key="type.id"
:value="type"
as="template"
>
<li
:class="[
active ? 'bg-red-200 text-amber-900' : 'text-gray-900',
'relative cursor-default select-none py-2 pl-10 pr-4',
]"
>
<span :class="[selected ? 'font-medium' : 'font-normal', 'block truncate']">
{{ type.type }}
<small v-if="type.passphrase">(passwortgeschützt)</small>
<small v-if="type.nscdr">(standard-Auslieferung)</small>
</span>
<span v-if="selected" class="absolute inset-y-0 left-0 flex items-center pl-3 text-primary">
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<p class="flex flex-row text-sm">
<InformationCircleIcon class="text-gray-500 h-5 w-5" /> Wenn kein Typ ausgewählt ist, werden die Standard-Typen
zur Verfügung gestellt: <br />
-> {{ defaultTypes }}
</p>
<br />
<TextCopy :copyText="generatedLink" />
<br />
</div>
<RouterLink
:to="{ name: 'public-calendar' }"
title="Zur öffentlichen Kalender Anzeige"
class="absolute top-3 right-3"
target="_blank"
>
<CalendarDaysIcon class="text-gray-500 h-5 w-5" />
</RouterLink>
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal">schließen</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { RouterLink } from "vue-router";
import { useModalStore } from "@/stores/modal";
import { useCalendarTypeStore } from "@/stores/admin/calendarType";
import type { CalendarTypeViewModel } from "@/viewmodels/admin/calendarType.models";
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, ListboxLabel } from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import TextCopy from "@/components/TextCopy.vue";
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/vue/24/outline";
import { host } from "@/serverCom";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
selectedTypes: [] as Array<CalendarTypeViewModel>,
};
},
computed: {
...mapState(useModalStore, ["data"]),
...mapState(useCalendarTypeStore, ["calendarTypes"]),
defaultTypes() {
return this.calendarTypes
.filter((t) => t.nscdr)
.map((t) => t.type)
.join(", ");
},
generatedLink() {
let extend = this.selectedTypes.map((t) => [t.type, t.passphrase].filter((at) => at).join(":"));
return `webcal://${host || window.location.host}/api/public/calendar${extend.length == 0 ? "" : "?types=" + extend.join("&types=")}`;
},
},
mounted() {
this.fetchCalendarTypes();
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
...mapActions(useCalendarTypeStore, ["fetchCalendarTypes"]),
},
});
</script>

View file

@ -6,6 +6,8 @@
<EyeIcon v-if="calendarType.nscdr" class="w-5 h-5" />
</div>
<p>{{ calendarType.type }}</p>
<small v-if="calendarType.passphrase">(passwortgeschützt)</small>
<small v-if="calendarType.nscdr">(standard-Auslieferung)</small>
</div>
<div class="flex flex-row">
<RouterLink

View file

@ -14,9 +14,13 @@
<label for="color">Farbe</label>
</div>
<div class="flex flex-row items-center gap-2">
<input type="checkbox" id="nscdr" />
<input type="checkbox" id="nscdr" v-model="nscdr" />
<label for="nscdr">Standard Kalender Auslieferung (optional)</label>
</div>
<div v-if="!nscdr">
<label for="passphrase">Passphrase (optional)</label>
<input type="text" id="passphrase" />
</div>
<div class="flex flex-row gap-2">
<button primary type="submit" :disabled="status == 'loading' || status?.status == 'success'">erstellen</button>
@ -59,6 +63,7 @@ export default defineComponent({
return {
status: null as null | "loading" | { status: "success" | "failed"; reason?: string },
timeout: undefined as any,
nscdr: false as boolean,
};
},
beforeUnmount() {
@ -75,6 +80,7 @@ export default defineComponent({
type: formData.type.value,
color: formData.color.value,
nscdr: formData.nscdr.checked,
passphrase: formData.passphrase.value,
};
this.createCalendarType(createCalendarType)
.then(() => {

View file

@ -0,0 +1,36 @@
<template>
<div class="w-full md:max-w-md">
<div class="flex flex-col items-center">
<p class="text-xl font-medium">Kalenderlink</p>
</div>
<br />
<TextCopy :copyText="generatedLink" />
<div class="flex flex-row justify-end">
<div class="flex flex-row gap-4 py-2">
<button primary-outline @click="closeModal">schließen</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from "vue";
import { mapState, mapActions } from "pinia";
import { useModalStore } from "@/stores/modal";
import TextCopy from "@/components/TextCopy.vue";
import { host } from "@/serverCom";
</script>
<script lang="ts">
export default defineComponent({
computed: {
generatedLink() {
return `webcal://${host || window.location.host}/api/public/calendar`;
},
},
methods: {
...mapActions(useModalStore, ["closeModal"]),
},
});
</script>

View file

@ -1,6 +1,6 @@
<template>
<div class="w-full h-full flex flex-row gap-4">
<div class="max-w-full flex grow gap-4 md:flex-row flex-col">
<div class="max-w-full flex grow gap-4 flex-col">
<slot name="main"></slot>
</div>
</div>

View file

@ -81,7 +81,7 @@ const router = createRouter({
{
path: "",
name: "admin-default",
component: () => import("@/views/RouterView.vue"),
component: () => import("@/views/admin/ViewSelect.vue"),
},
{
path: "club",
@ -525,6 +525,28 @@ const router = createRouter({
},
],
},
{
path: "/public",
name: "public",
component: () => import("@/views/public/View.vue"),
children: [
{
path: "",
name: "public-default",
component: () => import("@/views/notFound.vue"),
},
{
path: "calendar",
name: "public-calendar",
component: () => import("@/views/public/calendar/Calendar.vue"),
},
{
path: "calendar-explain",
name: "public-calendar-explain",
component: () => import("@/views/public/calendar/CalendarExplain.vue"),
},
],
},
{
path: "/nopermissions",
name: "nopermissions",

View file

@ -4,8 +4,11 @@ import router from "./router";
let devMode = process.env.NODE_ENV === "development";
let host = devMode ? "localhost:5000" : process.env.SERVER_ADDRESS || "";
let url = devMode ? "http://" + host : (host ? "https://" : "") + host;
const http = axios.create({
baseURL: (devMode ? "http://localhost:5000" : "") + "/api",
baseURL: url + "/api",
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
@ -81,4 +84,4 @@ export async function refreshToken(): Promise<void> {
});
}
export { http };
export { http, host };

View file

@ -36,6 +36,7 @@ export const useCalendarTypeStore = defineStore("calendarType", {
type: calendarType.type,
nscdr: calendarType.nscdr,
color: calendarType.color,
passphrase: calendarType.passphrase,
});
this.fetchCalendarTypes();
return result;
@ -45,6 +46,7 @@ export const useCalendarTypeStore = defineStore("calendarType", {
type: calendarType.type,
nscdr: calendarType.nscdr,
color: calendarType.color,
passphrase: calendarType.passphrase,
});
this.fetchCalendarTypes();
return result;

View file

@ -3,12 +3,14 @@ export interface CalendarTypeViewModel {
type: string;
nscdr: boolean;
color: string;
passphrase: string | null;
}
export interface CreateCalendarTypeViewModel {
type: string;
nscdr: boolean;
color: string;
passphrase?: string;
}
export interface UpdateCalendarTypeViewModel {
@ -16,4 +18,5 @@ export interface UpdateCalendarTypeViewModel {
type: string;
nscdr: boolean;
color: string;
passphrase?: string;
}

View file

@ -3,6 +3,7 @@
<template #topBar>
<div class="flex flex-row items-center justify-between pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Kalender</h1>
<LinkIcon class="text-gray-500 h-5 w-5 cursor-pointer" @click="openLinkModal" />
</div>
</template>
<template #diffMain>
@ -25,6 +26,7 @@ import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import { useCalendarStore } from "@/stores/admin/calendar";
import { useAbilityStore } from "@/stores/ability";
import { LinkIcon } from "@heroicons/vue/24/outline";
</script>
<script lang="ts">
@ -87,6 +89,11 @@ export default defineComponent({
e.event.id
);
},
openLinkModal(e: any) {
this.openModal(
markRaw(defineAsyncComponent(() => import("@/components/admin/club/calendar/CalendarLinkModal.vue")))
);
},
},
});

View file

@ -91,6 +91,12 @@ export default defineComponent({
},
// this.syncState is undefined, so it will never work
// beforeRouteLeave(to, from, next) {
// const answer = window.confirm('Do you really want to leave? you have unsaved changes!')
// if (answer) {
// next()
// } else {
// next(false)
// }
// if (this.syncState != "synced") {
// this.executeSyncAll = Date.now();
// this.wantToClose = true;

View file

@ -28,6 +28,10 @@
<input type="checkbox" id="nscdr" v-model="calendarType.nscdr" />
<label for="nscdr">Standard Kalender Auslieferung (optional)</label>
</div>
<div v-if="!calendarType.nscdr">
<label for="passphrase">Passphrase (optional)</label>
<input type="text" id="passphrase" v-model="calendarType.passphrase" />
</div>
<div class="flex flex-row justify-end gap-2">
<button primary-outline type="reset" class="!w-fit" :disabled="canSaveOrReset" @click="resetForm">
@ -111,6 +115,7 @@ export default defineComponent({
type: formData.type.value,
color: formData.color.value,
nscdr: formData.nscdr.checked,
passphrase: formData.passphrase.value,
};
this.status = "loading";
this.updateActiveCalendarType(updateCalendarType)

12
src/views/public/View.vue Normal file
View file

@ -0,0 +1,12 @@
<template>
<FullContent>
<template #main>
<RouterView />
</template>
</FullContent>
</template>
<script setup lang="ts">
import { RouterView } from "vue-router";
import FullContent from "../../layouts/FullContent.vue";
</script>

View file

@ -0,0 +1,97 @@
<template>
<MainTemplate :showBack="false">
<template #topBar>
<div class="flex flex-row items-center gap-4 pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-8">Kalender</h1>
<div class="grow"></div>
<LinkIcon class="text-gray-500 h-5 w-5 cursor-pointer" @click="openLinkModal" />
<RouterLink :to="{ name: 'public-calendar-explain' }">
<InformationCircleIcon class="text-gray-500 h-5 w-5 cursor-pointer" />
</RouterLink>
</div>
</template>
<template #diffMain>
<div class="flex flex-col w-full h-full gap-2 justify-between px-7 overflow-hidden">
<FullCalendar :options="calendarOptions" class="max-h-full h-full" />
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import { defineComponent, markRaw, defineAsyncComponent } from "vue";
import { mapActions, mapState } from "pinia";
import { useModalStore } from "@/stores/modal";
import MainTemplate from "@/templates/Main.vue";
import FullCalendar from "@fullcalendar/vue3";
import deLocale from "@fullcalendar/core/locales/de";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import { InformationCircleIcon, LinkIcon } from "@heroicons/vue/24/outline";
import type { CalendarViewModel } from "@/viewmodels/admin/calendar.models";
import { RouterLink } from "vue-router";
</script>
<script lang="ts">
export default defineComponent({
data() {
return {
calendars: [] as Array<CalendarViewModel>,
};
},
computed: {
formattedItems() {
return this.calendars.map((c) => ({
id: c.id,
title: c.title,
start: c.starttime,
end: c.endtime,
backgroundColor: c.type.color,
}));
},
calendarOptions() {
return {
timeZone: "local",
locale: deLocale,
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
headerToolbar: {
left: "dayGridMonth,timeGridWeek",
center: "title",
right: "prev,today,next",
},
eventDisplay: "block",
weekends: true,
editable: false,
selectable: false,
selectMirror: false,
dayMaxEvents: true,
weekNumbers: true,
displayEventTime: true,
nowIndicator: true,
weekText: "KW",
allDaySlot: false,
events: this.formattedItems,
};
},
},
mounted() {
this.fetchCalendars();
},
methods: {
...mapActions(useModalStore, ["openModal"]),
fetchCalendars() {
this.$http
.get("/public/calendar?output=json")
.then((result) => {
this.calendars = result.data;
})
.catch((err) => {});
},
openLinkModal(e: any) {
this.openModal(markRaw(defineAsyncComponent(() => import("@/components/public/calendar/CalendarLinkModal.vue"))));
},
},
});
</script>

View file

@ -0,0 +1,67 @@
<template>
<MainTemplate :showBack="false">
<template #headerInsert>
<RouterLink
:to="{
name: 'public-calendar',
}"
class="mid:hidden text-primary"
>
zum Kalender
</RouterLink>
</template>
<template #topBar>
<div class="flex flex-row items-center gap-2 pt-5 pb-3 px-7">
<h1 class="font-bold text-xl h-fit">Kalender-Erklärung - WebCal Kalendar einbinden</h1>
</div>
</template>
<template #main>
<div class="instruction">
<h2>iOS: Webcal-Link einbinden</h2>
<ol>
<li>Öffne die <strong>"Einstellungen"</strong> auf deinem iPhone oder iPad.</li>
<li>Scrolle nach unten und wähle <strong>"Kalender"</strong>.</li>
<li>Tippe auf <strong>"Accounts"</strong> und dann auf <strong>"Account hinzufügen"</strong>.</li>
<li>Wähle <strong>"Andere"</strong> und anschließend <strong>"Kalender abonnieren"</strong>.</li>
<li>Gib den <strong>Webcal-Link</strong> in das Feld ein und tippe auf <strong>"Weiter"</strong>.</li>
<li>Der Kalender wird nun hinzugefügt und im Standard-Kalender angezeigt.</li>
</ol>
<!-- <img src="images/ios_calendar_step1.png" alt="iOS Kalender Einstellungen" /> -->
<!-- <img src="images/ios_calendar_step2.png" alt="Webcal-Link eingeben" /> -->
</div>
<br />
<div class="instruction">
<h2>Android: Webcal-Link einbinden</h2>
<p>
Auf Android-Geräten musst du den Webcal-Link über den Google Kalender hinzufügen, da es keine direkte Option
gibt:
</p>
<ol>
<li>
Öffne auf einem Computer oder im Browser den
<strong> <a href="https://calendar.google.com" target="_blank">Google Kalender</a> </strong>.
</li>
<li>Klicke auf das <strong>"+"-Symbol</strong> neben "Weitere Kalender" (links im Menü).</li>
<li>Wähle <strong>"Per URL"</strong> aus.</li>
<li>Gib den <strong>Webcal-Link</strong> ein und klicke auf <strong>"Kalender hinzufügen"</strong>.</li>
<li>Öffne die Google Kalender App auf deinem Android-Gerät und synchronisiere die Änderungen.</li>
</ol>
<!-- <img src="images/android_google_calendar_step1.png" alt="Google Kalender im Browser" /> -->
<!-- <img src="images/android_google_calendar_step2.png" alt="Webcal-Link einfügen" /> -->
</div>
<div class="instruction">
<h2>Hinweise</h2>
<ul>
<li>Der Webcal-Link muss korrekt formatiert sein (beginnt mit <strong>webcal://</strong>).</li>
<li>Wenn der Kalender nicht sofort angezeigt wird, überprüfe die Synchronisierungseinstellungen.</li>
<li>Für Android kann es hilfreich sein, die Google Kalender App zu aktualisieren.</li>
</ul>
</div>
</template>
</MainTemplate>
</template>
<script setup lang="ts">
import MainTemplate from "@/templates/Main.vue";
</script>