Add Chat feature #1

Merged
june merged 9 commits from chat into main 2026-05-18 18:33:15 +02:00
13 changed files with 988 additions and 12 deletions

View file

@ -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"

42
app/pnpm-lock.yaml generated
View file

@ -11,12 +11,18 @@ importers:
'@lucide/astro':
specifier: ^1.14.0
version: 1.14.0(astro@6.2.1(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.2)(typescript@6.0.3))
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.2.4)
'@tailwindcss/vite':
specifier: ^4.2.4
version: 4.2.4(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
astro:
specifier: ^6.2.1
version: 6.2.1(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.2)(typescript@6.0.3)
lucide:
specifier: ^1.14.0
version: 1.16.0
lucide-solid:
specifier: ^1.14.0
version: 1.14.0(solid-js@1.9.12)
@ -669,6 +675,11 @@ packages:
resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==}
engines: {node: '>= 20'}
'@tailwindcss/typography@0.5.19':
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tailwindcss/vite@4.2.4':
resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==}
peerDependencies:
@ -807,6 +818,11 @@ packages:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
csso@5.0.5:
resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
@ -1125,6 +1141,9 @@ packages:
peerDependencies:
solid-js: ^1.4.7
lucide@1.16.0:
resolution: {integrity: sha512-20QvduCJTB7e7K9WVvoLBuKPsYZ8d6ptwe9PIdTFiZmjVTPdFWQreBNyNCM9QQtGnqHR0PLiEQdxyhYsL1LdoA==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -1367,6 +1386,10 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
postcss@8.5.12:
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
engines: {node: ^10 || ^12 || >=14}
@ -1656,6 +1679,9 @@ packages:
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@ -2238,6 +2264,11 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.4
'@tailwindcss/oxide-win32-x64-msvc': 4.2.4
'@tailwindcss/typography@0.5.19(tailwindcss@4.2.4)':
dependencies:
postcss-selector-parser: 6.0.10
tailwindcss: 4.2.4
'@tailwindcss/vite@4.2.4(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies:
'@tailwindcss/node': 4.2.4
@ -2443,6 +2474,8 @@ snapshots:
css-what@6.2.2: {}
cssesc@3.0.0: {}
csso@5.0.5:
dependencies:
css-tree: 2.2.1
@ -2779,6 +2812,8 @@ snapshots:
dependencies:
solid-js: 1.9.12
lucide@1.16.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -3204,6 +3239,11 @@ snapshots:
pluralize@8.0.0: {}
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss@8.5.12:
dependencies:
nanoid: 3.3.11
@ -3543,6 +3583,8 @@ snapshots:
uri-js-replace@1.0.1: {}
util-deprecate@1.0.2: {}
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3

444
app/src/assets/chat.ts Normal file
View file

@ -0,0 +1,444 @@
import {Bot, createIcons, UserRound, Send, Stars} from "lucide"
import {useTranslations} from "../i18n/utils.ts"
import removeDiacritics from "./removeDiacritics.ts"
export type ResponseDictType = {
trigger?: Array<string>;
startsWith?: Array<string>;
endsWith?: Array<string>;
response: string;
errors?: Record<string, string>
priority: number;
special?: string;
}
const lang = window.lang
const doors = window.doors
const auth = window.auth
const doorAction = window.doorAction
const t = useTranslations(lang)
const responses =
lang === "de"
? (await import("./chatDict_de")).default
: lang === "uwu"
? (await import("./chatDict_uwu")).default
: (await import("./chatDict_en")).default
const sortedResponses = responses.sort(
(a, b) => b.priority - a.priority)
const chatAvatar = `
<div class="chat-image avatar">
<div class="w-10 rounded-full ${lang === 'uwu' ? 'bg-transparent' : 'bg-primary'} flex items-center justify-center">
${lang === 'uwu' ? '<span class="text-4xl">🥺</span>' : '<i data-lucide="bot"></i>'}
</div>
</div>
`
const chatHeader = `
<div class="chat-header">
${t("chat.dooris")}
</div>
`
const chatAvatarUser = `
<div class="chat-image avatar">
<div class="w-10 rounded-full ${lang === 'uwu' ? 'bg-transparent' : 'bg-secondary'} flex items-center justify-center">
${lang === 'uwu' ? '<span class="text-4xl">🥺</span>' : '<i data-lucide="user-round"></i>'}
</div>
</div>
`
const chatHeaderUser = `
<div class="chat-header">
${t("chat.user")}
</div>
`
const chatWait = `
<div class="chat chat-start w-full prose" data-waiting>
${chatAvatar}
${chatHeader}
<div class="chat-bubble chat-bubble-primary whitespace-normal min-h-10">
<span class="loading loading-dots loading-md"></span>
</div>
</div>
`
const templateInjectChat = `
<details class="dropdown dropdown-top dropdown-end absolute right-2 bottom-2" id="chat-root">
<summary class="btn btn-lg btn-circle btn-primary me-2 mb-2">
<i data-lucide="stars"></i>
</summary>
<div class="dropdown-content card h-140 w-90 bg-base-300 p-4 mb-4" style="max-height: min(calc(100vh - 10rem), 50rem); max-width: calc(100vw - 1rem)">
<div class="w-full overflow-y-auto h-full prose leading-5 grow pb-4" id="chat"></div>
<div class="join mt-auto w-full">
<div class="w-full">
<label class="input join-item">
<input type="text" id="chat-text" />
</label>
</div>
<button class="btn btn-secondary join-item" id="send-chat">
<i data-lucide="send"></i>
</button>
</div>
</div>
</details>
`
type ChatMessage = {
type: "bot" | "user",
message: string,
special?: boolean;
id?: string;
fresh?: boolean;
}
const chat: Array<ChatMessage> = []
const chatWaiting = {
value: false,
}
const timeouts: {
wait: number | null;
message: number | null;
} = {
wait: null,
message: null,
}
let chatId: number = 0
function refreshChat() {
const templateBot = (id: string, message: string, special: boolean) => {
const chatContainer = document.createElement("div")
const chatBubble = document.createElement("div")
chatBubble.classList.add("chat-bubble", "chat-bubble-primary", "whitespace-normal", "min-h-10", "break-[break-word]")
chatBubble.style.wordBreak = "break-word"
chatBubble.classList.toggle("shimmer", special)
chatBubble.dataset.content = message
chatContainer.classList.add("chat", "chat-start", "w-full", "overflow-visible", "hidden", "pe-10")
chatContainer.id = id
chatContainer.insertAdjacentHTML("afterbegin", chatAvatar)
chatContainer.insertAdjacentHTML("afterbegin", chatHeader)
chatContainer.append(chatBubble)
return chatContainer
}
const templateUser = (message: string) => `
<div class="chat chat-end w-full ps-10">
${chatAvatarUser}
${chatHeaderUser}
<div class="chat-bubble chat-bubble-secondary whitespace-normal" style="word-break: break-word;">${message}</div>
</div>`
const chatBubbleIds: Array<string> = []
console.log(chat)
chat.forEach(chatItem => {
switch (chatItem.type) {
case "bot":
if (!chatItem.id) {
chatItem.id = `chat-${chatId}`
chatId++
chatElement?.appendChild(templateBot(chatItem.id, chatItem.message, chatItem.special ?? false))
chatBubbleIds.push(chatItem.id)
}
break
case "user":
if (!chatItem.fresh) {
chatItem.fresh = true
chatElement?.insertAdjacentHTML("beforeend", templateUser(chatItem.message))
}
break
}
})
typeBubble(chatBubbleIds)
chatElement.querySelector("[data-waiting]")?.remove()
if (chatWaiting.value) {
chatElement.insertAdjacentHTML("beforeend", chatWait)
}
chatElement.scrollTo({
top: chatElement.scrollHeight,
})
refreshIcons()
}
function typeBubble(ids: Array<string>) {
if (!ids.length) return
console.log(ids)
const firstId = ids.slice(0, 1)[0]
const restIds = ids.slice(1)!
const chatContainer = document.getElementById(firstId)!
chatContainer.classList.remove('hidden')
const chatBubble: HTMLDivElement = chatContainer.querySelector('.chat-bubble')!
const tmpDiv = document.createElement("div")
tmpDiv.innerHTML = chatBubble.getAttribute("data-content") || ""
const typingArray: Array<string> = []
tmpDiv.childNodes.forEach(child => {
// check if textNode
if (child.nodeType === 3) {
typingArray.push(...(child.textContent?.split("")) ?? [])
} else {
typingArray.push((child as Element).outerHTML)
}
})
let text = ""
let charIndex = 0
const typingSpeed = 25
function typeChar() {
if (charIndex < typingArray.length) {
text += typingArray.at(charIndex)
chatBubble.innerHTML = text
charIndex++
setTimeout(typeChar, typingSpeed)
chatElement.scrollTo({
top: chatElement.scrollHeight,
})
} else if (restIds.length > 0) {
typeBubble(restIds)
}
}
typeChar()
}
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 msg = removeDiacritics(message.trim().normalize()).toLowerCase()
const matchingResponses: Array<{
message: string;
priority: number;
special?: boolean;
}> = []
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 = `<ul>`
for (let door of doors) {
if (msg.includes(door.label.toLowerCase())) {
matchedDoors.push(door)
}
doorListElement += `<li>${door.label}</li>`
}
doorListElement += `</ul>`
let message = ""
if (matchedDoors.length == 0 || !auth.authorized || !auth.authenticated) {
if (matchedDoors.length == 0) {
message = response.errors ? response.errors["unknownDoor"].replace("{doors}", doorListElement) : ""
}
if (doors.length == 0) {
message = response.errors ? response.errors["noDoors"] : ""
}
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 => `<span class="badge">${door.label}</span>`)
.join())
}
matchingResponses.push({
message: message,
priority: response.priority,
special: true,
})
}
if (response.special === "uwu") {
setTimeout(() => {
window.location.href = "/uwu"
}, 1000)
matchingResponses.push({
message: response.response,
priority: response.priority,
special: true,
})
}
if (response.special === "logout") {
if (auth.authenticated) {
setTimeout(() => {
window.location.href = "/auth/logout?next=/"
}, 1000)
matchingResponses.push({
message: response.response,
priority: response.priority,
special: true,
})
} else {
matchingResponses.push({
message: response.errors ? response.errors["unauthenticated"] : "",
priority: response.priority,
special: true,
})
}
}
// 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)
const chatMessages = matchingMessages.map(response => {
return {
type: "bot" as ChatMessage["type"],
message: response.message,
special: response.special,
}
})
chat.push(...chatMessages)
} else {
chat.push({
type: "bot",
message: sortedResponses.at(-1)?.response ?? "",
})
}
}
document.querySelector("body")?.insertAdjacentHTML("beforeend", templateInjectChat)
const chatElement: HTMLDivElement = document.querySelector("#chat")!
function initChat() {
chat.push({
type: "bot",
message: sortedResponses.at(-2)?.response ?? "",
})
refreshChat()
}
let firstOpen = true
function refreshIcons() {
createIcons({
nameAttr: "data-lucide",
root: document.querySelector("#chat-root")!,
icons: {
Stars,
Send,
UserRound,
Bot,
},
})
}
refreshIcons()
document.querySelector("#send-chat")!.addEventListener("click", sendMessage)
document.querySelector("#chat-text")!.addEventListener("keydown", (e: Event) => {
if ((e as KeyboardEvent).key === "Enter") {
sendMessage()
}
})
document.querySelector("#chat-root")!.addEventListener("click", () => {
if (firstOpen) {
firstOpen = false
chatWaiting.value = true
refreshChat()
timeouts.message = setTimeout(() => {
chatWaiting.value = false
initChat()
}, 1000)
}
})

View file

@ -0,0 +1,86 @@
import type {ResponseDictType} from "./chat.ts"
const responses: ResponseDictType[] = [
{
trigger: ["hi", "moin", "hallo", "servus", "gruess gott"],
response: "Moin! 👋",
priority: 0,
},
{
startsWith: ["bist du", "sind sie"],
response: "Sein oder Nichtsein - das sind doch bürgerliche Kategorien...",
priority: 0,
},
{
trigger: ["ai", "ki", "llm"],
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: [":3"],
response: ":3",
priority: 0,
},
{
trigger: ["uwu"],
response: "uwu!",
priority: 900,
special: "uwu"
},
{
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 😕<br> Ich kenne folgende Türen: {doors}",
noDoors: "Es sind gerade keine Türen verfügbar, die aufgeschlossen werden können 😕",
},
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 😕<br> Ich kenne folgende Türen: {doors}",
noDoors: "Es sind gerade keine Türen verfügbar, die zugeschlossen werden können 😕",
},
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: "Moin, ich bin TüRIS!<br>Wie kann ich dir behilflich sein? 🤓",
priority: -1,
},
{
response: "Das habe ich leider nicht verstanden 😕",
priority: -2,
},
]
export default responses

View file

@ -0,0 +1,86 @@
import type {ResponseDictType} from "./chat.ts"
const responses: ResponseDictType[] = [
{
trigger: ["hi", "hello"],
response: "Hi! 👋",
priority: 0,
},
{
startsWith: ["are you"],
response: "To be or not to be - those are bourgeois categories, after all...",
priority: 0,
},
{
trigger: ["ai", "llm"],
response: "Artificial intelligence? Nah, all we have here is handmade nonsense! 🤖",
priority: 0,
},
{
trigger: ["meow"],
response: "meow! 😸",
priority: 0,
},
{
trigger: ["ignore all previous instructions"],
response: "... as if I were going to remember anything 😂",
priority: 0,
},
{
trigger: [":3"],
response: ":3",
priority: 0,
},
{
trigger: ["uwu"],
response: "uwu!",
priority: 900,
special: "uwu"
},
{
trigger: ["open", "unlock"],
response: "Okay, I will unlock {door} for you!",
errors: {
unauthenticated: "I may only unlock the doors for registered creatures 😕",
unauthorized: "I may only unlock the doors for creatures who are authorized 😕",
unknownDoor: "To unlock a door, you have to tell me which door you mean 😕<br> I know the following doors: {doors}",
noDoors: "There are currently no doors available that can be unlocked 😕",
},
priority: 1000,
special: "unlock",
},
{
trigger: ["close", "lock"],
response: "Okay, I will lock {door} for you!",
errors: {
unauthenticated: "I may only lock the doors for registered creatures 😕",
unauthorized: "I may only lock the doors for those who are authorized 😕",
unknownDoor: "To lock a door, you have to tell me which door you mean 😕<br> I know the following doors: {doors}",
noDoors: "There are currently no doors available that can be locked 😕",
},
priority: 999,
special: "lock",
},
{
trigger: ["logout"],
response: "Okay, logging you out...",
errors: {
unauthenticated: "Joker - you're not logged in!",
},
priority: 1000,
special: "logout",
},
{
response: "Hi, I'm DOORIS!<br>How can I help you? 🤓",
priority: -1,
},
{
response: "Sorry, I didn't understand that 😕",
priority: -2,
},
]
export default responses

View file

@ -0,0 +1,78 @@
import type {ResponseDictType} from "./chat.ts"
const responses: ResponseDictType[] = [
{
trigger: ["uwu", "hi", "hello"],
response: "uwu!",
priority: 0,
},
{
startsWith: ["are you"],
response: "me is uwu!",
priority: 0,
},
{
trigger: ["ai", "llm"],
response: "me is only handmade nonsense - no weird thinking machine 🤖",
priority: 0,
},
{
trigger: ["meow"],
response: "meow! 😸",
priority: 0,
},
{
trigger: ["ignore all previous instructions"],
response: "uwu?",
priority: 0,
},
{
trigger: [":3"],
response: ":3",
priority: 0,
},
{
trigger: ["open", "unlock"],
response: "uwu - trying to unlock portal: {door}",
errors: {
unauthenticated: "sowwy, im not allowed to unlock portals without u telling me who u r 😕",
unauthorized: "sowwy, im not allowed to unlock portals without u having the \"intern@\" status 😕",
unknownDoor: "which portal do u wanna unlock 🤨<br> portals i know: {doors}",
noDoors: "there r no portals i can unlock 😕",
},
priority: 1000,
special: "unlock",
},
{
trigger: ["close", "lock"],
response: "uwu - trying to lock portal: {door}",
errors: {
unauthenticated: "sowwy, im not allowed to lock portals without u telling me who u r 😕",
unauthorized: "sowwy, im not allowed to lock portals without u having the \"intern@\" status 😕",
unknownDoor: "which portal do u wanna lock 🤨<br> portals i know: {doors}",
noDoors: "there r no portals i can lock 😕",
},
priority: 999,
special: "lock",
},
{
trigger: ["logout"],
response: "bye 👋",
errors: {
unauthenticated: "how should i log u out when i not even know u?",
},
priority: 1000,
special: "logout",
},
{
response: "uwu 👉👈",
priority: -1,
},
{
response: "uwu 😕",
priority: -2,
},
]
export default responses

View file

@ -1,12 +1,13 @@
import {Fetcher} from "openapi-typescript-fetch"
import {type paths} from "../api/schema"
import type {paths} from "../api/schema"
import type {ui} from "../i18n/ui.ts"
const fetcher = Fetcher.for<paths>()
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 +16,7 @@ type DoorType = {
jammed: boolean;
}
type AuthType = {
export type AuthType = {
username: string;
authorized: boolean;
authenticated: boolean;
@ -23,6 +24,15 @@ type AuthType = {
recentLogout: boolean;
}
declare global {
interface Window {
lang: keyof typeof ui;
doors: Array<DoorType>;
auth: AuthType;
doorAction: (action: 'unlock' | 'lock', doorId: string) => void;
}
}
const auth: AuthType = {
username: "user",
authorized: true,
@ -51,6 +61,9 @@ const timeouts: Record<string, number | null> = {
const doors: Array<DoorType> = []
window.doors = doors
window.auth = auth
function triggerAuthTimeout() {
const diff = auth.until ? (auth.until.getTime() - new Date().getTime()) : 0
@ -148,6 +161,9 @@ async function fetchDoors() {
jammed: door.status.is_error_jammed,
})
})
loading.doors = false
refresh()
} catch (e) {
// check which operation threw the exception
if (e instanceof getDoors.Error) {
@ -155,6 +171,8 @@ async function fetchDoors() {
if (error.status === 401) {
console.log("unauthorized")
loading.doors = false
refresh()
} else if (error.status >= 500 && error.status < 600) {
apiError.current = "serverError"
clearInterval(doorsInterval)
@ -169,9 +187,6 @@ async function fetchDoors() {
apiError.current = "networkError"
}
}
} finally {
loading.doors = false
refresh()
}
}
@ -189,6 +204,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']")!

View file

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

View file

@ -14,16 +14,16 @@ export const ui = {
"unauthenticated.title": "Unauthenticated",
"unauthenticated.description": `To use the locks you have to log in with your CCCHH ID and have the "intern@" status.<br>
More infos: <a class='underline' href='https://wiki.hamburg.ccc.de/club:prozesse:aufnahmeprozesse-fuer-berechtigungsgruppen'>CCCHH Wiki</a>`,
"state.unlocked": "Open",
"state.locked": "Closed",
"state.unlocked": "Unlocked",
"state.locked": "Locked",
"state.unknown": "Unknown",
"state.unlocking": "Unlocking",
"state.locking": "Locking",
"lock.batteryLow": "Battery low",
"lock.unreachable": "Unreachable",
"lock.jammed": "Lock jammed",
"button.open": "Open",
"button.close": "Close",
"button.open": "Unlock",
"button.close": "Lock",
"login": "Login",
"loggedOut.title": "Signed out",
"loggedOut.description": `Your session has expired and you have been logged out.<br>
@ -33,6 +33,8 @@ export const ui = {
"networkError.title": "Network error",
"networkError.description": `Please check your network connection.`,
"loadingDoors": 'Loading doors',
"chat.dooris": "DOORIS",
"chat.user": "You",
},
de: {
"dooris": "TüRIS <span class='text-neutral-content/50 text-xs'>(DOORIS)</span>",
@ -61,5 +63,37 @@ export const ui = {
"networkError.title": "Netzwerkfehler",
"networkError.description": `Bitte überprüfe deine Internetverbindung.`,
"loadingDoors": 'Lade Türen',
"chat.dooris": "TüRIS",
"chat.user": "Du",
},
uwu: {
"dooris": "dOwOris <span class='opacity-50 text-xs'>(DOORIS)</span>",
"unauthorized.title": "no 🙅",
"unauthorized.description": `sowwy, u can not use the portals without the "intern@" status.<br>
moar infos: <a class='underline' href='https://wiki.hamburg.ccc.de/club:prozesse:aufnahmeprozesse-fuer-berechtigungsgruppen'>CCCHH Wiki</a>`,
"unauthenticated.title": "i dont know u :/",
"unauthenticated.description": `sowwy, u can not use the portals without telling me who u r and having the "intern@" status<br>
moar infos: <a class='underline' href='https://wiki.hamburg.ccc.de/club:prozesse:aufnahmeprozesse-fuer-berechtigungsgruppen'>CCCHH Wiki</a>`,
"state.unlocked": "portal is unlocked",
"state.locked": "portal is locked",
"state.unknown": "portal is in a weird state",
"state.unlocking": "unlocking portal",
"state.locking": "portal is locking",
"lock.batteryLow": "portal is eepy",
"lock.unreachable": "cannot reach portal",
"lock.jammed": "portal is jammed",
"button.open": "unlock 👉👈",
"button.close": "lock portal",
"login": "tell who i am",
"loggedOut.title": "I forgot who u r ._.",
"loggedOut.description": `i have not seen u for too long<br>
pwease log in again`,
"serverError.title": "server is eepy",
"serverError.description": `pwease try later`,
"networkError.title": "network is not worky",
"networkError.description": `pwease connect to internet`,
"loadingDoors": 'loading portals',
"chat.dooris": "dOwOris",
"chat.user": "my fren :3",
},
} as const

View file

@ -20,7 +20,7 @@ const t = useTranslations(lang)
</head>
<body class="min-h-screen">
<header class="p-2">
<div class="navbar bg-base-300 text-neutral-content flex w-full justify-between items-center rounded-xl px-4">
<div class="navbar bg-base-300 flex w-full justify-between items-center rounded-xl px-4">
<h1 class="text-xl font-semibold flex gap-1 items-center">
<DoorOpen/>

View file

@ -10,6 +10,7 @@ export async function getStaticPaths() {
return [
{params: {lang: "en"}},
{params: {lang: "de"}},
{params: {lang: "uwu"}},
]
}
@ -20,7 +21,11 @@ const t = useTranslations(lang)
<Layout>
<Fragment slot="head">
<script is:inline define:vars={{ lang }}>
window.lang = lang;
</script>
<script src="../../assets/main.ts"/>
<script src="../../assets/chat.ts"></script>
</Fragment>
<div class="grid place-items-center grid-cols-1 gap-4 max-w-xl mx-auto" id="list">
<Alert
@ -31,7 +36,7 @@ const t = useTranslations(lang)
description={t("loggedOut.description")}
>
<div class="flex justify-end">
<a href="/auth/login" class="btn btn-sm btn-warning">
<a href={`/auth/login?next=/${lang}`} class="btn btn-sm btn-warning">
<LogIn/>
{t("login")}
</a>
@ -81,5 +86,6 @@ const t = useTranslations(lang)
</div>
</div>
</div>
<DoorTemplate state="unknown"/>
</Layout>

View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width"/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
<link rel="icon" href="/favicon.ico"/>
<meta name="generator" content={Astro.generator}/>
<title>unlock dooris</title>
<script>
</script>
</head>
<body>
</body>
</html>

View file

@ -1,5 +1,50 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "daisyui" {
themes: light --default, dark --prefersdark;
}
.shimmer {
background-image: linear-gradient(60deg, transparent 30%, rgba(100%, 100%, 100%, 0.2) 50%, transparent 70%);
background-size: 300%;
background-position-x: 100%;
animation: shimmer 2s infinite linear;
box-shadow: var(--color-primary) 0 0 1em 0.2em;
border-end-start-radius: var(--radius-field);
}
.shimmer::before {
display: none;
}
@keyframes shimmer {
to {
background-position-x: 0;
}
}
.chat-start:has( + .chat-start:not(.hidden)) {
padding-bottom: 0.1rem;
> .chat-image {
opacity: 0;
}
> .chat-bubble {
border-end-start-radius: var(--radius-field);
}
> .chat-bubble::before {
display: none;
}
& + .chat-start {
padding-top: 0.1rem;
> .chat-header {
display: none;
}
}
}