Merge remote-tracking branch 'origin/main' into 22-semi-offline-room-finder

This commit is contained in:
survellow
2024-09-23 23:12:58 +02:00
22 changed files with 495 additions and 188 deletions

View File

@ -48,7 +48,9 @@
"vite": "^5.2.11", "vite": "^5.2.11",
"vite-plugin-pwa": "^0.20.0", "vite-plugin-pwa": "^0.20.0",
"vitest": "^1.6.0", "vitest": "^1.6.0",
"vue-tsc": "^1.8.27" "vue-tsc": "^1.8.27",
"workbox-core": "^7.1.0",
"workbox-precaching": "^7.1.0"
} }
}, },
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
@ -2981,28 +2983,6 @@
} }
} }
}, },
"node_modules/@types/eslint": {
"version": "8.56.10",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz",
"integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==",
"dev": true,
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
}
},
"node_modules/@types/eslint-scope": {
"version": "3.7.7",
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
"dev": true,
"peer": true,
"dependencies": {
"@types/eslint": "*",
"@types/estree": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
@ -3824,11 +3804,12 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/acorn-import-assertions": { "node_modules/acorn-import-attributes": {
"version": "1.9.0", "version": "1.9.5",
"resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
"integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true, "peer": true,
"peerDependencies": { "peerDependencies": {
"acorn": "^8" "acorn": "^8"
@ -4631,10 +4612,11 @@
"dev": true "dev": true
}, },
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.16.1", "version": "5.17.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.1.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz",
"integrity": "sha512-4U5pNsuDl0EhuZpq46M5xPslstkviJuhrdobaRDBk2Jy2KO37FDAJl4lb2KlNabxT0m4MTK2UHNrsAcphE8nyw==", "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.4", "graceful-fs": "^4.2.4",
@ -6329,10 +6311,11 @@
} }
}, },
"node_modules/micromatch": { "node_modules/micromatch": {
"version": "4.0.7", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"braces": "^3.0.3", "braces": "^3.0.3",
"picomatch": "^2.3.1" "picomatch": "^2.3.1"
@ -7662,6 +7645,7 @@
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -8407,22 +8391,22 @@
"dev": true "dev": true
}, },
"node_modules/webpack": { "node_modules/webpack": {
"version": "5.91.0", "version": "5.94.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.91.0.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz",
"integrity": "sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==", "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@types/eslint-scope": "^3.7.3",
"@types/estree": "^1.0.5", "@types/estree": "^1.0.5",
"@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/ast": "^1.12.1",
"@webassemblyjs/wasm-edit": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1",
"@webassemblyjs/wasm-parser": "^1.12.1", "@webassemblyjs/wasm-parser": "^1.12.1",
"acorn": "^8.7.1", "acorn": "^8.7.1",
"acorn-import-assertions": "^1.9.0", "acorn-import-attributes": "^1.9.5",
"browserslist": "^4.21.10", "browserslist": "^4.21.10",
"chrome-trace-event": "^1.0.2", "chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.16.0", "enhanced-resolve": "^5.17.1",
"es-module-lexer": "^1.2.1", "es-module-lexer": "^1.2.1",
"eslint-scope": "5.1.1", "eslint-scope": "5.1.1",
"events": "^3.2.0", "events": "^3.2.0",
@ -8806,7 +8790,8 @@
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.1.0.tgz", "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.1.0.tgz",
"integrity": "sha512-5KB4KOY8rtL31nEF7BfvU7FMzKT4B5TkbYa2tzkS+Peqj0gayMT9SytSFtNzlrvMaWgv6y/yvP9C0IbpFjV30Q==", "integrity": "sha512-5KB4KOY8rtL31nEF7BfvU7FMzKT4B5TkbYa2tzkS+Peqj0gayMT9SytSFtNzlrvMaWgv6y/yvP9C0IbpFjV30Q==",
"dev": true "dev": true,
"license": "MIT"
}, },
"node_modules/workbox-expiration": { "node_modules/workbox-expiration": {
"version": "7.1.0", "version": "7.1.0",
@ -8844,6 +8829,7 @@
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.1.0.tgz", "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.1.0.tgz",
"integrity": "sha512-LyxzQts+UEpgtmfnolo0hHdNjoB7EoRWcF7EDslt+lQGd0lW4iTvvSe3v5JiIckQSB5KTW5xiCqjFviRKPj1zA==", "integrity": "sha512-LyxzQts+UEpgtmfnolo0hHdNjoB7EoRWcF7EDslt+lQGd0lW4iTvvSe3v5JiIckQSB5KTW5xiCqjFviRKPj1zA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"workbox-core": "7.1.0", "workbox-core": "7.1.0",
"workbox-routing": "7.1.0", "workbox-routing": "7.1.0",

View File

@ -1,7 +1,7 @@
{ {
"name": "htwkalender", "name": "htwkalender",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@ -53,6 +53,8 @@
"vite": "^5.2.11", "vite": "^5.2.11",
"vite-plugin-pwa": "^0.20.0", "vite-plugin-pwa": "^0.20.0",
"vitest": "^1.6.0", "vitest": "^1.6.0",
"vue-tsc": "^1.8.27" "vue-tsc": "^1.8.27",
"workbox-core": "^7.1.0",
"workbox-precaching": "^7.1.0"
} }
} }

63
frontend/public/sw.js Normal file
View File

@ -0,0 +1,63 @@
import { precacheAndRoute, cleanupOutdatedCaches } from "workbox-precaching";
import { clientsClaim } from "workbox-core";
self.skipWaiting();
clientsClaim();
cleanupOutdatedCaches();
// Precache the files from the manifest
precacheAndRoute(self.__WB_MANIFEST);
// Custom precaching logic for the /api/modules endpoint
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open("api-modules-cache").then((cache) => {
return fetch("/api/modules")
.then((response) => {
if (response.ok) {
return cache.put("/api/modules", response);
}
throw new Error("Failed to fetch /api/modules");
})
.catch((error) => {
console.error("Precaching /api/modules failed:", error);
});
}),
);
});
// Custom precaching logic for the /api/schedule/rooms endpoint
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open("room-schedule-cache").then((cache) => {
return fetch("/api/schedule/rooms")
.then((response) => {
if (response.ok) {
return cache.put("/api/schedule/rooms", response);
}
throw new Error("Failed to fetch /api/schedule/rooms");
})
.catch((error) => {
console.error("Precaching /api/schedule/rooms failed:", error);
});
}),
);
});
// StaleWhileRevalidate strategy for the /api/schedule/rooms endpoint
self.addEventListener("fetch", (event) => {
if (event.request.url.includes("/api/schedule/rooms")) {
event.respondWith(
caches.open("room-schedule-cache").then((cache) => {
return cache.match(event.request).then((response) => {
const fetchPromise = fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return response || fetchPromise;
});
}),
);
}
});

View File

@ -15,7 +15,8 @@
//along with this program. If not, see <https://www.gnu.org/licenses/>. //along with this program. If not, see <https://www.gnu.org/licenses/>.
import { Module } from "../model/module"; import { Module } from "../model/module";
import { Calendar } from "../model/calendar"; import tokenStore from "@/store/tokenStore.ts";
import { Calendar } from "@/model/calendar.ts";
export async function getCalender(token: string): Promise<Module[]> { export async function getCalender(token: string): Promise<Module[]> {
const request = new Request("/api/collections/feeds/records/" + token, { const request = new Request("/api/collections/feeds/records/" + token, {
@ -24,9 +25,10 @@ export async function getCalender(token: string): Promise<Module[]> {
return await fetch(request).then((response) => { return await fetch(request).then((response) => {
if (response.ok) { if (response.ok) {
return response return response.json().then((calendarResponse: Calendar) => {
.json() tokenStore().feed = calendarResponse;
.then((calendarResponse: Calendar) => calendarResponse.modules); return calendarResponse.modules;
});
} else { } else {
return []; return [];
} }

View File

@ -0,0 +1,9 @@
<script lang="ts" setup>
const version = __APP_VERSION__;
</script>
<template>
<p>
{{ version }}
</p>
</template>

View File

@ -84,28 +84,78 @@ const forwardToHTWKalendar = () => {
}); });
}; };
const actions = computed(() => [ const shareData = computed(() => ({
{ title: t("calendarLink.shareTitle"),
label: t("calendarLink.copyToClipboard"), text: t("calendarLink.shareText") + getLink(),
icon: "pi pi-copy", url: "https://" + domain + "/",
command: copyToClipboard, }));
},
{ const shareLink = () => {
label: t("calendarLink.toGoogleCalendar"), if (typeof navigator.share === 'function' && navigator.canShare(shareData.value)) {
icon: "pi pi-google", navigator
command: forwardToGoogle, .share(shareData.value)
}, .then(() => {
{ toast.add({
label: t("calendarLink.toMicrosoftCalendar"), severity: "info",
icon: "pi pi-microsoft", summary: t("calendarLink.shareToastSummary"),
command: forwardToMicrosoft, detail: t("calendarLink.shareToastNotification"),
}, life: 3000,
{ });
label: t("calendarLink.toHTWKalendar"), })
icon: "pi pi-home", .catch(() => {
command: forwardToHTWKalendar, toast.add({
}, severity: "error",
]); summary: t("calendarLink.shareToastError"),
detail: t("calendarLink.shareToastErrorDetail"),
life: 3000,
});
});
} else {
toast.add({
severity: "error",
summary: t("calendarLink.shareToastError"),
detail: t("calendarLink.shareToastErrorDetail"),
life: 3000,
});
}
};
const actions = computed(() => {
const actionList = [
{
label: t("calendarLink.copyToClipboard"),
icon: "pi pi-copy",
command: copyToClipboard,
},
{
label: t("calendarLink.toGoogleCalendar"),
icon: "pi pi-google",
command: forwardToGoogle,
},
{
label: t("calendarLink.toMicrosoftCalendar"),
icon: "pi pi-microsoft",
command: forwardToMicrosoft,
},
{
label: t("calendarLink.toHTWKalendar"),
icon: "pi pi-home",
command: forwardToHTWKalendar,
}
];
if (typeof navigator.share === 'function' && navigator.canShare(shareData.value)) {
actionList.push({
label: t("calendarLink.share"),
icon: "pi pi-share-alt",
command: shareLink,
});
}
return actionList;
}
);
</script> </script>
<template> <template>

View File

@ -46,12 +46,21 @@ const props = defineProps({
}, },
}); });
type CalendarEvent = {
title: string;
start: Date | null;
end: Date | null;
notes: string;
allDay: boolean;
location: string;
};
const op = ref(); const op = ref();
const clickedEvent = ref(); const clickedEvent : Ref<CalendarEvent | null> = ref(null);
const toggle = (info: EventClickArg) => { const toggle = (info: EventClickArg) => {
const start = !info.event.start ? "" : info.event.start; const start = !info.event.start ? null : info.event.start;
const end = !info.event.end ? "" : info.event.end; const end = !info.event.end ? null : info.event.end;
if (op.value.visible) { if (op.value.visible) {
clickedEvent.value = null; clickedEvent.value = null;
@ -199,12 +208,13 @@ watch(mobilePage, () => {
</FullCalendar> </FullCalendar>
<OverlayPanel ref="op"> <OverlayPanel ref="op">
<div> <div v-if="clickedEvent">
<h3>{{ clickedEvent.title }}</h3> <h3>{{ clickedEvent.title }}</h3>
<p>Location: {{ clickedEvent.location }}</p> <p><b>{{ $t("calendarViewer.location") }}:</b> {{ clickedEvent.location }}</p>
<p>Start: {{ clickedEvent.start?.toLocaleString() }}</p> <p><b>{{ $t("calendarViewer.start") }}:</b> {{ clickedEvent.start ? $d(clickedEvent.start, "long") : ""}}</p>
<p>End: {{ clickedEvent.end?.toLocaleString() }}</p> <p><b>{{ $t("calendarViewer.end") }}:</b> {{ clickedEvent.end ? $d(clickedEvent.end, "long") : "" }}</p>
<p>Notes: {{ clickedEvent.notes }}</p> <p><b>{{ $t("calendarViewer.notes") }}:</b></p>
<p v-for="note in clickedEvent.notes.split('\n')" class="note-line" :key="note">{{ note }}</p>
</div> </div>
</OverlayPanel> </OverlayPanel>
</template> </template>
@ -215,4 +225,9 @@ watch(mobilePage, () => {
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
} }
.note-line {
margin: 0;
margin-left: 2rem;
}
</style> </style>

View File

@ -68,12 +68,7 @@ updateLocale(settingsStore().locale);
<template #value="slotProps"> <template #value="slotProps">
<div v-if="slotProps.value" class="flex align-items-center"> <div v-if="slotProps.value" class="flex align-items-center">
<div class="mr-2 flag">{{ displayIcon(slotProps.value) }}</div> <div class="mr-2 flag">{{ displayIcon(slotProps.value) }}</div>
<div <div>
style="
font-family: &quot;Twemoji Country Flags&quot;,
&quot;Helvetica&quot;, &quot;Comic Sans&quot;, serif;
"
>
{{ displayCountry(slotProps.value) }} {{ displayCountry(slotProps.value) }}
</div> </div>
</div> </div>

View File

@ -0,0 +1,39 @@
<script lang="ts" setup>
import { useToast } from "primevue/usetoast";
const Toast = useToast();
const updateCache = () => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then((boolean) => {
if (boolean) {
Toast.add({
severity: "success",
summary: "Service Worker",
detail: "Service Worker has been unregistered",
life: 1000,
});
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
Toast.add({
severity: "error",
summary: "Service Worker",
detail: "Service Worker could not be unregistered",
life: 1000,
});
}
});
});
}
};
</script>
<template>
<Button @click="updateCache()">
{{ $t("settings.reloadPwa") }}
</Button>
</template>

View File

@ -21,14 +21,14 @@ import FullCalendar from "@fullcalendar/vue3";
import dayGridPlugin from "@fullcalendar/daygrid"; import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction"; import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid"; import timeGridPlugin from "@fullcalendar/timegrid";
import {computed, ComputedRef, inject, ref, Ref, watch} from "vue"; import { computed, ComputedRef, inject, ref, Ref, watch } from "vue";
import {CalendarOptions, DatesSetArg, EventInput} from "@fullcalendar/core"; import { CalendarOptions, DatesSetArg, EventInput } from "@fullcalendar/core";
import {useI18n} from "vue-i18n"; import { useI18n } from "vue-i18n";
import allLocales from "@fullcalendar/core/locales-all"; import allLocales from "@fullcalendar/core/locales-all";
import router from "@/router"; import router from "@/router";
import {formatYearMonthDay} from "@/helpers/dates"; import { formatYearMonthDay } from "@/helpers/dates";
import {isValid} from "date-fns"; import { isValid } from "date-fns";
import {RoomOccupancyList} from "@/model/roomOccupancyList"; import { RoomOccupancyList } from "@/model/roomOccupancyList";
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });
@ -84,15 +84,15 @@ function transformData(data: RoomOccupancyList) {
return []; return [];
} }
return data return data
.decodeOccupancy( .decodeOccupancy(
selectedRoom.value, selectedRoom.value,
new Date(currentDateFrom.value), new Date(currentDateFrom.value),
new Date(currentDateTo.value), new Date(currentDateTo.value),
) )
.map((event, index) => ({ .map((event, index) => ({
id: index, id: index,
event: event, event: event,
})); }));
} }
const occupations = computed(() => { const occupations = computed(() => {

View File

@ -149,7 +149,14 @@
"copyToClipboard": "Link kopieren", "copyToClipboard": "Link kopieren",
"toGoogleCalendar": "Google Kalender", "toGoogleCalendar": "Google Kalender",
"toMicrosoftCalendar": "Microsoft Kalender", "toMicrosoftCalendar": "Microsoft Kalender",
"toHTWKalendar": "HTWKalender" "toHTWKalendar": "HTWKalender",
"share": "Teilen",
"shareTitle": "HTWKalender",
"shareText": "Ich habe diesen Stundenplan für dich mit dem HTWKalender erstellt. Schau ihn dir hier an: ",
"shareToastSummary": "Information",
"shareToastNotification": "Link zum Stundenplan erfolgreich geteilt",
"shareToastError": "Fehler",
"shareToastErrorDetail": "Link konnte nicht geteilt werden. Möglicherweise wird die Funktion nicht unterstützt."
}, },
"calendarPreview": { "calendarPreview": {
"preview": "Vorschau", "preview": "Vorschau",
@ -157,6 +164,12 @@
"module": "Modul", "module": "Modul",
"course": "Gruppe" "course": "Gruppe"
}, },
"calendarViewer": {
"location": "Ort",
"start": "Beginn",
"end": "Ende",
"notes": "Notizen"
},
"faqView": { "faqView": {
"headline": "Fragen und Antworten", "headline": "Fragen und Antworten",
"firstQuestion": "Wie funktioniert das Kalender erstellen mit dem HTWKalender?", "firstQuestion": "Wie funktioniert das Kalender erstellen mit dem HTWKalender?",
@ -255,13 +268,16 @@
"headline": "Dein Kalender", "headline": "Dein Kalender",
"subTitle": "Hier findest du die Kalenderansicht von deinem persönlichen Feed.", "subTitle": "Hier findest du die Kalenderansicht von deinem persönlichen Feed.",
"searchPlaceholder": "Token", "searchPlaceholder": "Token",
"searchButton": "Kalender laden" "searchButton": "Kalender laden",
"invalidToken": "Ungültiger Token"
}, },
"settings": { "settings": {
"headline": "Einstellungen", "headline": "Einstellungen",
"subTitle": "Hier kannst du deine Einstellungen bearbeiten.", "subTitle": "Hier kannst du deine Einstellungen bearbeiten.",
"language": "Sprache einstellen", "language": "Sprache einstellen",
"darkMode": "Design auswählen", "darkMode": "Design auswählen",
"defaultPage": "Standardseite" "defaultPage": "Standardseite",
"version": "App-Version",
"reloadPwa": "App neu laden"
} }
} }

View File

@ -97,8 +97,13 @@
"error": "Error", "error": "Error",
"successDetail": "calendar successfully deleted", "successDetail": "calendar successfully deleted",
"errorDetail": "calendar could not be deleted", "errorDetail": "calendar could not be deleted",
"successDetailLoad": "calendar successfully loaded" "successDetailLoad": "calendar successfully loaded",
} "info": "info"
},
"calendarContainsNoModules": "calendar contains no modules",
"noCalendarInOfflineMode": "calendar isn't available in offline mode",
"calendarLoadedFromOfflineMode": "calendar loaded from offline cache",
"calendarLoadedFromServer": "calendar loaded from web"
}, },
"additionalModules": { "additionalModules": {
"subTitle": "select additional modules that are not listed in the regular semester for your course", "subTitle": "select additional modules that are not listed in the regular semester for your course",
@ -149,7 +154,14 @@
"copyToClipboard": "copy to clipboard", "copyToClipboard": "copy to clipboard",
"toGoogleCalendar": "Google Calendar", "toGoogleCalendar": "Google Calendar",
"toMicrosoftCalendar": "Microsoft Calendar", "toMicrosoftCalendar": "Microsoft Calendar",
"toHTWKalendar": "HTWKalender" "toHTWKalendar": "HTWKalender",
"share": "Share",
"shareTitle": "HTWKalender",
"shareText": "Ive created this timetable for you with HTWKalender. Check it out here: ",
"shareToastSummary": "Information",
"shareToastNotification": "Timetable link successfully shared",
"shareToastError": "Error",
"shareToastErrorDetail": "Link could not be shared. The feature might not be supported."
}, },
"calendarPreview": { "calendarPreview": {
"preview": "preview", "preview": "preview",
@ -157,6 +169,12 @@
"module": "module", "module": "module",
"course": "course" "course": "course"
}, },
"calendarViewer": {
"location": "location",
"start": "start",
"end": "end",
"notes": "notes"
},
"faqView": { "faqView": {
"headline": "faq", "headline": "faq",
"firstQuestion": "How does calendar creation work with HTWKalender?", "firstQuestion": "How does calendar creation work with HTWKalender?",
@ -255,13 +273,16 @@
"headline": "user calendar", "headline": "user calendar",
"subTitle": "Here you can find the calendar view of your personal feed.", "subTitle": "Here you can find the calendar view of your personal feed.",
"searchPlaceholder": "calendar token", "searchPlaceholder": "calendar token",
"searchButton": "load calendar" "searchButton": "load calendar",
"invalidToken": "invalid token"
}, },
"settings": { "settings": {
"headline": "Settings", "headline": "Settings",
"subTitle": "Here you can change your settings.", "subTitle": "Here you can change your settings.",
"language": "Choose your language", "language": "Choose your language",
"darkMode": "Switch page theme", "darkMode": "Switch page theme",
"defaultPage": "Default page" "defaultPage": "Default page",
"version": "Version",
"reloadPwa": "Reload PWA"
} }
} }

View File

@ -149,7 +149,14 @@
"copyToClipboard": "クリップボードにコピー", "copyToClipboard": "クリップボードにコピー",
"toGoogleCalendar": "Googleカレンダー", "toGoogleCalendar": "Googleカレンダー",
"toMicrosoftCalendar": "Microsoftカレンダー", "toMicrosoftCalendar": "Microsoftカレンダー",
"toHTWKalendar": "HTWカレンダー" "toHTWKalendar": "HTWカレンダー",
"share": "共有",
"shareTitle": "HTWカレンダー",
"shareText": "HTWカレンダーでこの時間割を作成しました。こちらで確認してください: ",
"shareToastSummary": "情報",
"shareToastNotification": "時間割のリンクが正常に共有されました",
"shareToastError": "エラー",
"shareToastErrorDetail": "リンクを共有できませんでした。機能がサポートされていない可能性があります。"
}, },
"calendarPreview": { "calendarPreview": {
"preview": "プレビュー", "preview": "プレビュー",
@ -157,6 +164,12 @@
"module": "モジュール", "module": "モジュール",
"course": "コース" "course": "コース"
}, },
"calendarViewer": {
"location": "場所",
"start": "開始",
"end": "終了",
"notes": "メモ"
},
"faqView": { "faqView": {
"headline": "よくある質問", "headline": "よくある質問",
"firstQuestion": "HTWカレンダーを使用してカレンダーを作成するにはどうすればよいですか", "firstQuestion": "HTWカレンダーを使用してカレンダーを作成するにはどうすればよいですか",
@ -255,13 +268,16 @@
"headline": "ユーザーカレンダー", "headline": "ユーザーカレンダー",
"subTitle": "ここでは、個人のフィードのカレンダー表示を見つけることができます。", "subTitle": "ここでは、個人のフィードのカレンダー表示を見つけることができます。",
"searchPlaceholder": "カレンダートークン", "searchPlaceholder": "カレンダートークン",
"searchButton": "ロードカレンダー" "searchButton": "ロードカレンダー",
"invalidToken": "無効なトークン"
}, },
"settings": { "settings": {
"headline": "設定", "headline": "設定",
"subTitle": "ここで設定を編集できます。", "subTitle": "ここで設定を編集できます。",
"language": "言語", "language": "言語",
"darkMode": "ダークモード", "darkMode": "ダークモード",
"defaultPage": "デフォルトページ" "defaultPage": "デフォルトページ",
"version": "バージョン",
"reloadPwa": "PWAを再読み込み"
} }
} }

View File

@ -22,6 +22,7 @@ import App from "./App.vue";
import PrimeVue from "primevue/config"; import PrimeVue from "primevue/config";
import Badge from "primevue/badge"; import Badge from "primevue/badge";
import Button from "primevue/button"; import Button from "primevue/button";
import ButtonGroup from "primevue/buttongroup";
import Dropdown from "primevue/dropdown"; import Dropdown from "primevue/dropdown";
import Menu from "primevue/menu"; import Menu from "primevue/menu";
import Menubar from "primevue/menubar"; import Menubar from "primevue/menubar";
@ -85,6 +86,7 @@ i18n.setup();
app.use(i18n.vueI18n); app.use(i18n.vueI18n);
app.component("Badge", Badge); app.component("Badge", Badge);
app.component("Button", Button); app.component("Button", Button);
app.component("ButtonGroup", ButtonGroup);
app.component("Menu", Menu); app.component("Menu", Menu);
app.component("Menubar", Menubar); app.component("Menubar", Menubar);
app.component("Dialog", Dialog); app.component("Dialog", Dialog);

View File

@ -14,7 +14,7 @@
//You should have received a copy of the GNU Affero General Public License //You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>. //along with this program. If not, see <https://www.gnu.org/licenses/>.
import {Binary, Document} from "bson"; import { Binary, Document } from "bson";
import { AnonymizedOccupancy } from "./event"; import { AnonymizedOccupancy } from "./event";
import { import {
Duration, Duration,
@ -219,31 +219,38 @@ export class RoomOccupancyList {
// Iterate over all bits in the current byte // Iterate over all bits in the current byte
for (let bit_i = 0; bit_i < 8; bit_i++) { for (let bit_i = 0; bit_i < 8; bit_i++) {
const isOccupied = (byte & (1 << (7 - bit_i))) !== 0; const isOccupied = (byte & (1 << (7 - bit_i))) !== 0;
const calculateOccupancyBitIndex = (byte_i: number, bit_i: number) => byte_i * 8 + bit_i; const calculateOccupancyBitIndex = (byte_i: number, bit_i: number) =>
byte_i * 8 + bit_i;
if(firstOccupancyBit === null){ if (firstOccupancyBit === null) {
if (isOccupied) { if (isOccupied) {
firstOccupancyBit = calculateOccupancyBitIndex(byte_i, bit_i); firstOccupancyBit = calculateOccupancyBitIndex(byte_i, bit_i);
} }
} else { } else {
if (!isOccupied) { if (!isOccupied) {
const startTime = addMinutes(start, firstOccupancyBit * granularity); const startTime = addMinutes(
const endTime = addMinutes(start, calculateOccupancyBitIndex(byte_i, bit_i) * granularity); start,
firstOccupancyBit * granularity,
);
const endTime = addMinutes(
start,
calculateOccupancyBitIndex(byte_i, bit_i) * granularity,
);
// add event between start and end of a block of boolean true values // add event between start and end of a block of boolean true values
occupancyList.push( occupancyList.push(
new AnonymizedOccupancy( new AnonymizedOccupancy(
startTime.toISOString(), startTime.toISOString(),
endTime.toISOString(), endTime.toISOString(),
room, room,
false, false,
false, false,
), ),
); );
firstOccupancyBit = null; firstOccupancyBit = null;
} }
} }
}
} }
}
// add last event if it is still ongoing // add last event if it is still ongoing
if (firstOccupancyBit !== null) { if (firstOccupancyBit !== null) {
const startTime = addMinutes(start, firstOccupancyBit * granularity); const startTime = addMinutes(start, firstOccupancyBit * granularity);
@ -317,7 +324,8 @@ export class RoomOccupancyList {
json.granularity, json.granularity,
json.blocks, json.blocks,
json.rooms.map( json.rooms.map(
(room: { name: string, occupancy: Binary }) => new RoomOccupancy(room.name, room.occupancy), (room: { name: string; occupancy: Binary }) =>
new RoomOccupancy(room.name, room.occupancy),
), ),
); );
} }

View File

@ -15,10 +15,20 @@
//along with this program. If not, see <https://www.gnu.org/licenses/>. //along with this program. If not, see <https://www.gnu.org/licenses/>.
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { useLocalStorage } from "@vueuse/core";
import { Calendar } from "../model/calendar";
const tokenStore = defineStore("tokenStore", { const tokenStore = defineStore("tokenStore", {
state: () => ({ state: () => ({
token: "", token: "",
feed: useLocalStorage("feed", {
collectionId: "",
collectionName: "",
created: "",
id: "",
modules: [],
updated: "",
} as Calendar),
}), }),
persist: true, persist: true,
actions: { actions: {

View File

@ -50,22 +50,66 @@ function loadCalendar(): void {
moduleStore().removeAllModules(); moduleStore().removeAllModules();
tokenStore().setToken(token.value); tokenStore().setToken(token.value);
getCalender(token.value).then((data: Module[]) => { if (navigator.onLine) {
if (data.length > 0) { getCalender(token.value).then((data: Module[]) => {
data.forEach((module) => { if (data.length > 0) {
moduleStore().addModule(module); data.forEach((module) => {
}); moduleStore().addModule(module);
modules.value = moduleStore().modules; });
router.push("/edit-calendar"); modules.value = moduleStore().modules;
toast.add({
severity: "success",
summary: t("editCalendarView.toast.success"),
detail: t("editCalendarView.calendarLoadedFromServer"),
life: 3000,
});
router.push("/edit-calendar");
} else {
toast.add({
severity: "error",
summary: t("editCalendarView.toast.error"),
detail: t("editCalendarView.noCalendarFound"),
life: 3000,
});
}
});
} else {
if (tokenStore().feed.id === token.value) {
// get data from tokenStore feed if offline
const offlineModules = tokenStore().feed.modules;
if (offlineModules.length > 0) {
offlineModules.forEach((module) => {
moduleStore().addModule(module);
});
toast.add({
severity: "info",
summary: t("editCalendarView.toast.info"),
detail: t("editCalendarView.calendarLoadedFromOfflineMode"),
life: 3000,
});
} else {
toast.add({
severity: "info",
summary: t("editCalendarView.info"),
detail: t("editCalendarView.calendarContainsNoModules"),
life: 3000,
});
}
} else { } else {
toast.add({ toast.add({
severity: "error", severity: "error",
summary: t("editCalendarView.error"), summary: t("editCalendarView.toast.error"),
detail: t("editCalendarView.noCalendarFound"), detail: t("editCalendarView.noCalendarInOfflineMode"),
life: 3000, life: 3000,
}); });
return;
} }
}); modules.value = moduleStore().modules;
router.push("/edit-calendar");
}
} }
</script> </script>

View File

@ -3,6 +3,8 @@ import LocaleSwitcher from "@/components/LocaleSwitcher.vue";
import { ref } from "vue"; import { ref } from "vue";
import DarkModeSwitcher from "@/components/DarkModeSwitcher.vue"; import DarkModeSwitcher from "@/components/DarkModeSwitcher.vue";
import DefaultPageSwitcher from "@/components/DefaultPageSwitcher.vue"; import DefaultPageSwitcher from "@/components/DefaultPageSwitcher.vue";
import AppVersion from "@/components/AppVersion.vue";
import ReloadPwa from "@/components/ReloadPwa.vue";
const icon = "pi pi-cog"; const icon = "pi pi-cog";
const isDark = ref(true); const isDark = ref(true);
@ -61,11 +63,26 @@ function handleDarkModeToggled(isDarkVar: boolean) {
></DarkModeSwitcher> ></DarkModeSwitcher>
</div> </div>
</div> </div>
<div class="grid my-2">
<div class="col text-left">
{{ $t("settings.version") }}
</div>
<div class="col text-center">
<AppVersion />
</div>
</div>
<div class="grid my-2">
<div class="col text-center"></div>
<div class="col text-center">
<ReloadPwa />
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
<style lang="css" scoped>
.col { .col {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@ -3,15 +3,17 @@ import CalendarViewer from "@/components/CalendarViewer.vue";
import DynamicPage from "@/view/DynamicPage.vue"; import DynamicPage from "@/view/DynamicPage.vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { extractToken } from "@/helpers/token.ts"; import { extractToken, isToken } from "@/helpers/token.ts";
import { useToast } from "primevue/usetoast"; import { useToast } from "primevue/usetoast";
import moduleStore from "@/store/moduleStore.ts"; import moduleStore from "@/store/moduleStore.ts";
import tokenStore from "@/store/tokenStore.ts"; import tokenStore from "@/store/tokenStore.ts";
import router from "@/router";
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });
const toast = useToast(); const toast = useToast();
const token = ref(tokenStore().token || ("" as string)); const token = ref(tokenStore().token || ("" as string));
const calendarViewerRef = ref<InstanceType<typeof CalendarViewer>>();
// parse token from query parameter // parse token from query parameter
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@ -22,8 +24,6 @@ if (tokenFromUrl) {
loadCalendar(); loadCalendar();
} }
const calendarViewerRef = ref<InstanceType<typeof CalendarViewer>>();
function loadCalendar() { function loadCalendar() {
try { try {
token.value = extractToken(token.value); token.value = extractToken(token.value);
@ -50,6 +50,35 @@ function loadCalendar() {
}); });
} }
function shareLink() {
let datePart = router.currentRoute.value.query.date;
if (datePart != undefined) {
datePart = "&date=" + datePart;
} else {
datePart = "";
}
const link = "https://" + window.location.hostname + "/calendar/view?token=" + token.value + datePart;
const shareData = {
title: t("calendarLink.shareLinkTitle"),
text: t("calendarLink.shareLinkText"),
url: link,
};
if (typeof navigator.share === "function" && navigator.canShare(shareData)) {
navigator.share(shareData);
} else {
navigator.clipboard.writeText(link).then(() => {
toast.add({
severity: "info",
summary: t("calendarLink.copyToastSummary"),
detail: t("calendarLink.copyToastNotification"),
life: 3000,
});
});
}
}
onMounted(() => { onMounted(() => {
if (token.value && token.value !== "") { if (token.value && token.value !== "") {
//loadCalendar(); //loadCalendar();
@ -70,11 +99,23 @@ onMounted(() => {
:class="flexSpecs" :class="flexSpecs"
@keyup.enter="loadCalendar()" @keyup.enter="loadCalendar()"
/> />
<Button <ButtonGroup
:label="$t('userCalender.searchButton')" :class="flexSpecs"
icon="pi pi-refresh" class="flex no-wrap"
@click="loadCalendar()" :style="{ 'justify-content': 'space-between' }"
></Button> >
<Button
:label="$t('userCalender.searchButton')"
icon="pi pi-refresh"
class="flex-grow-1"
@click="loadCalendar()"
></Button>
<Button
icon="pi pi-share-alt"
:disabled="!isToken(token)"
@click="shareLink()"
></Button>
</ButtonGroup>
</template> </template>
<template #content> <template #content>
<CalendarViewer ref="calendarViewerRef" :token="tokenStore().token" /> <CalendarViewer ref="calendarViewerRef" :token="tokenStore().token" />

View File

@ -17,3 +17,5 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module "ical.js"; declare module "ical.js";
declare const __APP_VERSION__: string;

View File

@ -3,7 +3,7 @@
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"module": "ESNext", "module": "ESNext",
"types": ["node"], "types": ["node", "vite-plugin-pwa/vue"],
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */

View File

@ -28,7 +28,13 @@ export default defineConfig({
mode: "development", mode: "development",
base: "/", base: "/",
injectRegister: "auto", injectRegister: "auto",
includeAssets: ["favicon.ico", "apple-touch-icon.png", "mask-icon.svg"], includeAssets: [
"favicon.ico",
"apple-touch-icon.png",
"mask-icon.svg",
"robots.txt",
"sitemap.xml",
],
manifest: { manifest: {
name: "HTWKalender", name: "HTWKalender",
short_name: "HTWKalender", short_name: "HTWKalender",
@ -81,55 +87,15 @@ export default defineConfig({
], ],
}, },
registerType: "autoUpdate", registerType: "autoUpdate",
workbox: { strategies: "injectManifest",
injectManifest: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,json,vue,txt,woff2}", "/api/schedule/rooms"], globPatterns: ["**/*.{js,css,html,ico,png,svg,json,vue,txt,woff2}", "/api/schedule/rooms"],
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: ({ url }) => url.pathname.startsWith("/api/feed"),
method: "GET",
handler: "NetworkFirst",
options: {
cacheName: "calendar-feed-cache",
expiration: {
maxAgeSeconds: 12 * 60 * 60, // 12 hours
},
},
},
{
urlPattern: ("/api/schedule/rooms"),
method: "GET",
handler: "StaleWhileRevalidate",
options: {
cacheName: "room-schedule-cache",
expiration: {
maxAgeSeconds: 12 * 60 * 60, // 12 hours
},
},
},
{
urlPattern: /^https?.*/,
handler: "NetworkFirst",
options: {
cacheName: "https-calls",
expiration: {
maxEntries: 150,
maxAgeSeconds: 30 * 12 * 60 * 60, // 1 month
},
networkTimeoutSeconds: 10, // fall back to cache if api does not response within 10 seconds
},
},
],
},
devOptions: {
enabled: true,
/* when using generateSW the PWA plugin will switch to classic */
type: "module",
navigateFallback: "index.html",
suppressWarnings: true,
}, },
}), }),
], ],
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
},
resolve: { resolve: {
alias: { alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)), "@": fileURLToPath(new URL("./src", import.meta.url)),
@ -149,6 +115,9 @@ export default defineConfig({
}, },
}, },
}, },
build: {
sourcemap: true,
},
esbuild: { esbuild: {
supported: { supported: {
"top-level-await": true, "top-level-await": true,