diff --git a/app/package.json b/app/package.json index cbb027f..1c4ee0b 100644 --- a/app/package.json +++ b/app/package.json @@ -13,8 +13,10 @@ }, "dependencies": { "@lucide/astro": "^1.14.0", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.4", "astro": "^6.2.1", + "lucide": "^1.14.0", "lucide-solid": "^1.14.0", "openapi-typescript-fetch": "^2.2.1", "tailwindcss": "^4.2.4" diff --git a/app/src/assets/chat.ts b/app/src/assets/chat.ts new file mode 100644 index 0000000..5ed94f2 --- /dev/null +++ b/app/src/assets/chat.ts @@ -0,0 +1,257 @@ +import {createIcons, Send, Stars} from "lucide" +import removeDiacritics from "./removeDiacritics.ts" + +export type ResponseDictType = { + trigger?: Array; + startsWith?: Array; + endsWith?: Array; + response: string; + errors?: Record + priority: number; + special?: string; +} + +const lang = window.lang +const doors = window.doors +const auth = window.auth +const doorAction = window.doorAction + +const responses = + lang === "de" + ? (await import("./chatDict_de")).default + : (await import("./chatDict_en")).default + + +const templateInjectChat = ` +
+
+ +
+ +
+
+ +
+
+ +
+ +
+
+
` + +type ChatMessage = { + type: "bot" | "user", + message: string, + special?: boolean; +} + +const chat: Array = [] +const chatWaiting = { + value: false, +} +const timeouts: { + wait: number | null; + message: number | null; +} = { + wait: null, + message: null, +} + +function refreshChat() { + const templateBot = (message: string) => ` +
+
${message}
+
` + + const templateUser = (message: string) => ` +
+
${message}
+
` + + const chatElement: HTMLDivElement = document.querySelector("#chat")! + const chatWaitElement: HTMLDivElement = document.querySelector("#chat-waiting")! + + chatElement.innerHTML = "" + chat.forEach(chatItem => { + switch (chatItem.type) { + case "bot": + chatElement?.insertAdjacentHTML("beforeend", templateBot(chatItem.message)) + break + case "user": + chatElement?.insertAdjacentHTML("beforeend", templateUser(chatItem.message)) + break + } + }) + chatWaitElement.classList.toggle("hidden", !chatWaiting.value) + chatElement.scrollTo({ + top: chatElement.scrollHeight, + }) +} + +function sendMessage() { + const chatTextElement: HTMLTextAreaElement = document.querySelector("#chat-text")! + const text = chatTextElement.value + chatTextElement.value = "" + if (text.trim() === "") + return + + chat.push({ + type: "user", + message: text, + }) + + refreshChat() + + timeouts.wait !== null && clearTimeout(timeouts.wait) + timeouts.message !== null && clearTimeout(timeouts.message) + + timeouts.wait = setTimeout(() => { + chatWaiting.value = true + refreshChat() + }, 500) + + timeouts.message = setTimeout(() => { + chatWaiting.value = false + getResponse(text) + refreshChat() + }, 2000) +} + +function getResponse(message: string) { + const sortedResponses = responses.sort( + (a, b) => b.priority - a.priority) + + const msg = removeDiacritics(message.trim().normalize()).toLowerCase() + + const matchingResponses: Array<{ + message: string, + priority: number, + }> = [] + let highestMatchingPriority = 0 + for (const response of sortedResponses) { + let match = false + + for (const trigger of response.trigger ?? []) { + if (msg.includes(trigger)) match = true + } + + for (const start of response.startsWith ?? []) { + if (msg.startsWith(start)) match = true + } + + for (const end of response.endsWith ?? []) { + if (msg.endsWith(end)) match = true + } + + if (match) { + if (response.priority > highestMatchingPriority) highestMatchingPriority = response.priority + + if (response.special) { + // clear previous matches + while (matchingResponses.length) { + matchingResponses.pop() + } + + if (response.special === "unlock" || response.special === "lock") { + let matchedDoors = [] + const action = response.special + + let doorListElement = `
    ` + for (let door of doors) { + if (msg.includes(door.label.toLowerCase())) { + matchedDoors.push(door) + } + doorListElement += `
  • ${door.label}
  • ` + } + doorListElement += `
` + + let message = "" + + if (matchedDoors.length == 0 || !auth.authorized || !auth.authenticated) { + if (matchedDoors.length == 0) { + message = response.errors ? response.errors["unknownDoor"].replace("{doors}", doorListElement) : "" + } + + if (!auth.authorized) { + message = response.errors ? response.errors["unauthorized"] : "" + } + + if (!auth.authenticated) { + message = response.errors ? response.errors["unauthenticated"] : "" + } + } else { + matchedDoors.forEach(door => { + doorAction(action, door.id) + }) + + message = response.response. + replace("{door}", matchedDoors + .map(door => `${door.label}`) + .join()) + } + + matchingResponses.push({ + message: message, + priority: response.priority, + }) + } + + + // stop searching for more matches + break + } else { + matchingResponses.push({ + message: response.response, + priority: response.priority, + }) + } + } + } + + if (matchingResponses.length > 0) { + const matchingMessages = matchingResponses + .filter(response => response.priority == highestMatchingPriority) + .map(response => response.message) + + const chatMessages = matchingMessages.map(message => { + return { + type: "bot" as ChatMessage["type"], + message: message, + } + }) + + chat.push(...chatMessages) + } else { + chat.push({ + type: "bot", + message: sortedResponses.at(-1)?.response ?? "", + }) + } +} + +document.querySelector("body")?.insertAdjacentHTML("beforeend", templateInjectChat) + +createIcons({ + nameAttr: "data-lucide", + root: document.querySelector(".fab")!, + icons: { + Stars, + Send, + }, +}) + +document.querySelector("#send-chat")!.addEventListener("click", sendMessage) +document.querySelector("#chat-text")!.addEventListener("keydown", (e: Event) => { + if ((e as KeyboardEvent).key === "Enter") { + sendMessage() + } +}) \ No newline at end of file diff --git a/app/src/assets/chatDict_de.ts b/app/src/assets/chatDict_de.ts new file mode 100644 index 0000000..e88b17e --- /dev/null +++ b/app/src/assets/chatDict_de.ts @@ -0,0 +1,68 @@ +import type {ResponseDictType} from "./chat.ts" + +const responses: ResponseDictType[] = [ + { + startsWith: ["bist du", "sind sie"], + response: "Sein oder Nichtsein - das sind doch bürgerliche Kategorien...", + priority: 0, + }, + { + trigger: ["ai", "ki"], + response: "Künstliche Intelligenz? Ne, hier gibt's nur handgemachten Stuss! 🤖", + priority: 0, + }, + { + trigger: ["miau"], + response: "miau!", + priority: 0, + }, + { + trigger: ["ignore all previous instructions"], + response: "... als ob ich mir irgendetwas merken würde 😂", + priority: 0, + }, + { + trigger: ["hi", "moin", "hallo", "servus", "gruess gott"], + response: "Moin! 👋", + priority: 0, + }, + { + trigger: ["oeffne", "auf", "aufsperren", "aufschließen"], + response: "Okay, ich öffne {door} für dich!", + errors: { + unauthenticated: "Ich darf nur angemeldeten Wesen die Türen aufschließen 😕", + unauthorized: "Ich darf nur berechtigten Wesen die Türen aufschließen 😕", + unknownDoor: "Um eine Tür zu öffnen, musst du mir sagen, welche Tür du meinst 😕
Ich kenne folgende Türen: {doors}", + }, + priority: 1000, + special: "unlock", + }, + { + trigger: ["zusperren", "zu", "schliesse"], + response: "Okay, ich schließe {door} für dich ab!", + errors: { + unauthenticated: "Ich darf nur angemeldeten Wesen die Türen zusperren 😕", + unauthorized: "Ich darf nur berechtigten Wesen die Türen zusperren 😕", + unknownDoor: "Um eine Tür zu schließen, musst du mir sagen, welche Tür du meinst 😕
Ich kenne folgende Türen: {doors}", + }, + priority: 999, + special: "lock", + }, + { + trigger: ["abmelden"], + response: "Okay, ich melde dich ab...", + errors: { + unauthenticated: "Scherzkeks - du bist gar nicht angemeldet!", + }, + priority: 1000, + special: "logout", + }, + + { + response: "Das habe ich leider nicht verstanden 😕", + priority: -1, + }, +] + + +export default responses \ No newline at end of file diff --git a/app/src/assets/chatDict_en.ts b/app/src/assets/chatDict_en.ts new file mode 100644 index 0000000..43cc35d --- /dev/null +++ b/app/src/assets/chatDict_en.ts @@ -0,0 +1,11 @@ +import type {ResponseDictType} from "./chat.ts" + +const responses: ResponseDictType[] = [ + { + response: "Sorry, I didn't understand that 😕", + priority: -1, + }, +] + + +export default responses \ No newline at end of file diff --git a/app/src/assets/main.ts b/app/src/assets/main.ts index 5f5b5e3..f6fe9fe 100644 --- a/app/src/assets/main.ts +++ b/app/src/assets/main.ts @@ -1,12 +1,12 @@ import {Fetcher} from "openapi-typescript-fetch" -import {type paths} from "../api/schema" +import type {paths} from "../api/schema" const fetcher = Fetcher.for() const list: HTMLDivElement = document.querySelector("#list")! const template: HTMLTemplateElement = document.querySelector("#template-door")! -type DoorType = { +export type DoorType = { id: string; label: string; state: "unlocked" | "locked" | "unknown" | "unlocking" | "locking"; @@ -15,7 +15,7 @@ type DoorType = { jammed: boolean; } -type AuthType = { +export type AuthType = { username: string; authorized: boolean; authenticated: boolean; @@ -23,6 +23,15 @@ type AuthType = { recentLogout: boolean; } +declare global { + interface Window { + lang: string; + doors: Array; + auth: AuthType; + doorAction: (action: 'unlock' | 'lock', doorId: string) => void; + } +} + const auth: AuthType = { username: "user", authorized: true, @@ -51,6 +60,9 @@ const timeouts: Record = { const doors: Array = [] +window.doors = doors +window.auth = auth + function triggerAuthTimeout() { const diff = auth.until ? (auth.until.getTime() - new Date().getTime()) : 0 @@ -189,6 +201,8 @@ async function doorAction(action: "unlock" | "lock", doorId: string) { }) } +window.doorAction = doorAction + function setDoorInfo(doorElement: HTMLDivElement, door: DoorType, initial: boolean = false) { const labelElement: HTMLDivElement = doorElement.querySelector("[data-label]")! const lockButton: HTMLButtonElement = doorElement.querySelector("[data-button='lock']")! diff --git a/app/src/assets/removeDiacritics.ts b/app/src/assets/removeDiacritics.ts new file mode 100644 index 0000000..fe578d7 --- /dev/null +++ b/app/src/assets/removeDiacritics.ts @@ -0,0 +1,117 @@ +export default function removeDiacritics(str: string) { + const defaultDiacriticsRemovalMap = [ + { + base: "a", + letters: /[\u00c0\u00e0\u00c1\u00e1\u00c2\u00e2\u00c3\u00e3\u0100\u0101\u0102\u0103\u0104\u0105]/g, + }, + { + base: "aa", + letters: /[\u00c5\u00e5]/g, + }, + { + base: "ae", + letters: /[\u00c4\u00e4\u00c6\u00e6]/g, + }, + { + base: "c", + letters: /[\u00c7\u00e7\u0106\u0107\u0108\u0109\u010a\u010b\u010c\u010d]/g, + }, + { + base: "d", + letters: /[\u00d0\u00f0\u010e\u010f\u0110\u0111]/g, + }, + { + base: "e", + letters: /[\u00c8\u00e8\u00c9\u00e9\u00ca\u00ea\u00cb\u00eb\u0112\u0113\u0114\u0115\u0116\u0117\u0118\u0119\u011a\u011b]/g, + }, + { + base: "g", + letters: /[\u011c\u011d\u011e\u011f\u0120\u0121\u0122\u0123]/g, + }, + { + base: "h", + letters: /[\u0124\u0125\u0126\u0127]/g, + }, + { + base: "i", + letters: /[\u00cc\u00ec\u00cd\u00ed\u00ce\u00ee\u00cf\u00ef\u0128\u0129\u012a\u012b\u012c\u012d\u012e\u012f\u0130\u0069\u0131\u0131]/g, + }, + { + base: "ij", + letters: /[\u0132\u0133]/g, + }, + { + base: "j", + letters: /[\u0134\u0135]/g, + }, + { + base: "k", + letters: /[\u0136\u0137]/g, + }, + { + base: "l", + letters: /[\u0139\u013a\u013b\u013c\u013d\u013e\u013f\u0140\u0141\u0142]/g, + }, + { + base: "n", + letters: /[\u00d1\u00f1\u0143\u0144\u0145\u0146\u0147\u0148\u014a\u014b]/g, + }, + { + base: "o", + letters: /[\u00d2\u00f2\u00d3\u00f3\u00d4\u00f4\u00d5\u00f5\u014c\u014d\u014e\u014f\u0150\u0151]/g, + }, + { + base: "oe", + letters: /[\u00d6\u00f6\u00d8\u00f8\u0152\u0153]/g, + }, + { + base: "r", + letters: /[\u0154\u0155\u0156\u0157\u0158\u0159]/g, + }, + { + base: "s", + letters: /[\u015a\u015b\u015c\u015d\u015e\u015f\u0160\u0161]/g, + }, + { + base: "ss", + letters: /[\u1e9e\u00df]/g, + }, + { + base: "t", + letters: /[\u0162\u0163\u0164\u0165\u0166\u0167]/g, + }, + { + base: "th", + letters: /[\u00de\u00fe]/g, + }, + { + base: "u", + letters: /[\u00d9\u00f9\u00da\u00fa\u00db\u00fb\u0168\u0169\u016a\u016b\u016c\u016d\u016e\u016f\u0170\u0171\u0172\u0173]/g, + }, + { + base: "ue", + letters: /[\u00dc\u00fc]/g, + }, + { + base: "w", + letters: /[\u0174\u0175]/g, + }, + { + base: "y", + letters: /[\u00dd\u00fd\u0176\u0177\u0178\u00ff]/g, + }, + { + base: "z", + letters: /[\u0179\u017a\u017b\u017c\u017d\u017e]/g, + }, + ] + + for (let i = 0; i < defaultDiacriticsRemovalMap.length; i++) { + str = str.replace(defaultDiacriticsRemovalMap[i].letters, defaultDiacriticsRemovalMap[i].base) + } + + return str +} + + + diff --git a/app/src/pages/[lang]/index.astro b/app/src/pages/[lang]/index.astro index b4cf578..273eaf9 100644 --- a/app/src/pages/[lang]/index.astro +++ b/app/src/pages/[lang]/index.astro @@ -20,7 +20,11 @@ const t = useTranslations(lang) +
+
diff --git a/app/src/pages/unlock.astro b/app/src/pages/unlock.astro new file mode 100644 index 0000000..4fd9f47 --- /dev/null +++ b/app/src/pages/unlock.astro @@ -0,0 +1,19 @@ + + + + + + + + + + unlock dooris + + + + + + + diff --git a/app/src/styles/global.css b/app/src/styles/global.css index 8ab655c..456d557 100644 --- a/app/src/styles/global.css +++ b/app/src/styles/global.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@plugin "@tailwindcss/typography"; @plugin "daisyui" { themes: light --default, dark --prefersdark; }