improve chat ui
All checks were successful
Build Container / Build Container (push) Successful in 1m29s

This commit is contained in:
kritzl 2026-05-18 16:49:24 +02:00
commit 81923c4f8c
Signed by: kritzl
SSH key fingerprint: SHA256:5BmINP9VjZWaUk5Z+2CTut1KFhwLtd0ZynMekKbtViM
7 changed files with 170 additions and 48 deletions

View file

@ -1,4 +1,5 @@
import {createIcons, Send, Stars} from "lucide" import {Bot, createIcons, UserRound, Send, Stars} from "lucide"
import {useTranslations} from "../i18n/utils.ts"
import removeDiacritics from "./removeDiacritics.ts" import removeDiacritics from "./removeDiacritics.ts"
export type ResponseDictType = { export type ResponseDictType = {
@ -16,6 +17,8 @@ const doors = window.doors
const auth = window.auth const auth = window.auth
const doorAction = window.doorAction const doorAction = window.doorAction
const t = useTranslations(lang)
const responses = const responses =
lang === "de" lang === "de"
? (await import("./chatDict_de")).default ? (await import("./chatDict_de")).default
@ -25,19 +28,51 @@ const responses =
const sortedResponses = responses.sort( const sortedResponses = responses.sort(
(a, b) => b.priority - a.priority) (a, b) => b.priority - a.priority)
const templateInjectChat = ` const chatAvatar = `
<details class="dropdown dropdown-top dropdown-end absolute right-4 bottom-4" id="chat-root"> <div class="chat-image avatar">
<summary class="btn btn-lg btn-circle btn-primary"> <div class="w-10 rounded-full ${lang === 'uwu' ? 'bg-transparent' : 'bg-primary'} flex items-center justify-center">
<i data-lucide="stars"></i> ${lang === 'uwu' ? '<span class="text-4xl">🥺</span>' : '<i data-lucide="bot"></i>'}
</summary> </div>
<div class="dropdown-content card min-h-120 max-h-200 w-80 bg-base-300 p-4 mb-4"> </div>
<div class="w-full overflow-auto h-full prose leading-5" id="chat"></div> `
<div class="chat chat-start w-full hidden" id="chat-waiting">
<div class="chat-bubble chat-bubble-primary whitespace-normal"> 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> <span class="loading loading-dots loading-md"></span>
</div> </div>
</div> </div>
<div class="mt-4"></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="join mt-auto w-full">
<div class="w-full"> <div class="w-full">
<label class="input join-item"> <label class="input join-item">
@ -79,11 +114,14 @@ function refreshChat() {
const chatContainer = document.createElement("div") const chatContainer = document.createElement("div")
const chatBubble = document.createElement("div") const chatBubble = document.createElement("div")
chatBubble.classList.add("chat-bubble", "chat-bubble-primary", "whitespace-normal") 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.classList.toggle("shimmer", special)
chatBubble.id = id
chatBubble.dataset.content = message chatBubble.dataset.content = message
chatContainer.classList.add("chat", "chat-start", "w-full") 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) chatContainer.append(chatBubble)
return chatContainer return chatContainer
@ -91,10 +129,16 @@ function refreshChat() {
const templateUser = (message: string) => ` const templateUser = (message: string) => `
<div class="chat chat-end w-full"> <div class="chat chat-end w-full ps-10">
<div class="chat-bubble chat-bubble-secondary whitespace-normal">${message}</div> ${chatAvatarUser}
${chatHeaderUser}
<div class="chat-bubble chat-bubble-secondary whitespace-normal" style="word-break: break-word;">${message}</div>
</div>` </div>`
const chatBubbleIds: Array<string> = []
console.log(chat)
chat.forEach(chatItem => { chat.forEach(chatItem => {
switch (chatItem.type) { switch (chatItem.type) {
case "bot": case "bot":
@ -102,8 +146,7 @@ function refreshChat() {
chatItem.id = `chat-${chatId}` chatItem.id = `chat-${chatId}`
chatId++ chatId++
chatElement?.appendChild(templateBot(chatItem.id, chatItem.message, chatItem.special ?? false)) chatElement?.appendChild(templateBot(chatItem.id, chatItem.message, chatItem.special ?? false))
typeBubble(chatItem.id, () => { chatBubbleIds.push(chatItem.id)
})
} }
break break
case "user": case "user":
@ -115,16 +158,35 @@ function refreshChat() {
break break
} }
}) })
chatWaitElement.classList.toggle("hidden", !chatWaiting.value)
typeBubble(chatBubbleIds)
chatElement.querySelector("[data-waiting]")?.remove()
if (chatWaiting.value) {
chatElement.insertAdjacentHTML("beforeend", chatWait)
}
chatElement.scrollTo({ chatElement.scrollTo({
top: chatElement.scrollHeight, top: chatElement.scrollHeight,
}) })
refreshIcons()
} }
function typeBubble(id: string, callback: Function) { function typeBubble(ids: Array<string>) {
const element = document.getElementById(id)! 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") const tmpDiv = document.createElement("div")
tmpDiv.innerHTML = element.getAttribute("data-content") || "" tmpDiv.innerHTML = chatBubble.getAttribute("data-content") || ""
const typingArray: Array<string> = [] const typingArray: Array<string> = []
@ -144,14 +206,14 @@ function typeBubble(id: string, callback: Function) {
function typeChar() { function typeChar() {
if (charIndex < typingArray.length) { if (charIndex < typingArray.length) {
text += typingArray.at(charIndex) text += typingArray.at(charIndex)
element.innerHTML = text chatBubble.innerHTML = text
charIndex++ charIndex++
setTimeout(typeChar, typingSpeed) setTimeout(typeChar, typingSpeed)
chatElement.scrollTo({ chatElement.scrollTo({
top: chatElement.scrollHeight, top: chatElement.scrollHeight,
}) })
} else if (callback) { } else if (restIds.length > 0) {
callback() typeBubble(restIds)
} }
} }
@ -320,7 +382,7 @@ function getResponse(message: string) {
return { return {
type: "bot" as ChatMessage["type"], type: "bot" as ChatMessage["type"],
message: response.message, message: response.message,
special: response.special special: response.special,
} }
}) })
@ -335,7 +397,6 @@ function getResponse(message: string) {
document.querySelector("body")?.insertAdjacentHTML("beforeend", templateInjectChat) document.querySelector("body")?.insertAdjacentHTML("beforeend", templateInjectChat)
const chatElement: HTMLDivElement = document.querySelector("#chat")! const chatElement: HTMLDivElement = document.querySelector("#chat")!
const chatWaitElement: HTMLDivElement = document.querySelector("#chat-waiting")!
function initChat() { function initChat() {
chat.push({ chat.push({
@ -347,14 +408,20 @@ function initChat() {
let firstOpen = true let firstOpen = true
function refreshIcons() {
createIcons({ createIcons({
nameAttr: "data-lucide", nameAttr: "data-lucide",
root: document.querySelector("#chat-root")!, root: document.querySelector("#chat-root")!,
icons: { icons: {
Stars, Stars,
Send, Send,
UserRound,
Bot,
}, },
}) })
}
refreshIcons()
document.querySelector("#send-chat")!.addEventListener("click", sendMessage) document.querySelector("#send-chat")!.addEventListener("click", sendMessage)
document.querySelector("#chat-text")!.addEventListener("keydown", (e: Event) => { document.querySelector("#chat-text")!.addEventListener("keydown", (e: Event) => {

View file

@ -1,6 +1,11 @@
import type {ResponseDictType} from "./chat.ts" import type {ResponseDictType} from "./chat.ts"
const responses: ResponseDictType[] = [ const responses: ResponseDictType[] = [
{
trigger: ["hi", "moin", "hallo", "servus", "gruess gott"],
response: "Moin! 👋",
priority: 0,
},
{ {
startsWith: ["bist du", "sind sie"], startsWith: ["bist du", "sind sie"],
response: "Sein oder Nichtsein - das sind doch bürgerliche Kategorien...", response: "Sein oder Nichtsein - das sind doch bürgerliche Kategorien...",
@ -22,8 +27,8 @@ const responses: ResponseDictType[] = [
priority: 0, priority: 0,
}, },
{ {
trigger: ["hi", "moin", "hallo", "servus", "gruess gott"], trigger: [":3"],
response: "Moin! 👋", response: ":3",
priority: 0, priority: 0,
}, },
{ {
@ -68,7 +73,7 @@ const responses: ResponseDictType[] = [
{ {
response: "Moin!<br>Wie kann ich dir behilflich sein? 🤓", response: "Moin, ich bin TüRIS!<br>Wie kann ich dir behilflich sein? 🤓",
priority: -1, priority: -1,
}, },
{ {

View file

@ -1,6 +1,11 @@
import type {ResponseDictType} from "./chat.ts" import type {ResponseDictType} from "./chat.ts"
const responses: ResponseDictType[] = [ const responses: ResponseDictType[] = [
{
trigger: ["hi", "hello"],
response: "Hi! 👋",
priority: 0,
},
{ {
startsWith: ["are you"], startsWith: ["are you"],
response: "To be or not to be - those are bourgeois categories, after all...", response: "To be or not to be - those are bourgeois categories, after all...",
@ -22,8 +27,8 @@ const responses: ResponseDictType[] = [
priority: 0, priority: 0,
}, },
{ {
trigger: ["hi", "hello"], trigger: [":3"],
response: "Hi! 👋", response: ":3",
priority: 0, priority: 0,
}, },
{ {
@ -68,7 +73,7 @@ const responses: ResponseDictType[] = [
{ {
response: "Hi!<br>How can I help you? 🤓", response: "Hi, I'm DOORIS!<br>How can I help you? 🤓",
priority: -1, priority: -1,
}, },
{ {

View file

@ -1,6 +1,11 @@
import type {ResponseDictType} from "./chat.ts" import type {ResponseDictType} from "./chat.ts"
const responses: ResponseDictType[] = [ const responses: ResponseDictType[] = [
{
trigger: ["uwu", "hi", "hello"],
response: "uwu!",
priority: 0,
},
{ {
startsWith: ["are you"], startsWith: ["are you"],
response: "me is uwu!", response: "me is uwu!",
@ -22,8 +27,8 @@ const responses: ResponseDictType[] = [
priority: 0, priority: 0,
}, },
{ {
trigger: ["uwu", "hi", "hello"], trigger: [":3"],
response: "uwu!", response: ":3",
priority: 0, priority: 0,
}, },
{ {

View file

@ -1,5 +1,6 @@
import {Fetcher} from "openapi-typescript-fetch" 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 fetcher = Fetcher.for<paths>()
@ -25,7 +26,7 @@ export type AuthType = {
declare global { declare global {
interface Window { interface Window {
lang: string; lang: keyof typeof ui;
doors: Array<DoorType>; doors: Array<DoorType>;
auth: AuthType; auth: AuthType;
doorAction: (action: 'unlock' | 'lock', doorId: string) => void; doorAction: (action: 'unlock' | 'lock', doorId: string) => void;
@ -160,6 +161,9 @@ async function fetchDoors() {
jammed: door.status.is_error_jammed, jammed: door.status.is_error_jammed,
}) })
}) })
loading.doors = false
refresh()
} catch (e) { } catch (e) {
// check which operation threw the exception // check which operation threw the exception
if (e instanceof getDoors.Error) { if (e instanceof getDoors.Error) {
@ -167,6 +171,8 @@ async function fetchDoors() {
if (error.status === 401) { if (error.status === 401) {
console.log("unauthorized") console.log("unauthorized")
loading.doors = false
refresh()
} else if (error.status >= 500 && error.status < 600) { } else if (error.status >= 500 && error.status < 600) {
apiError.current = "serverError" apiError.current = "serverError"
clearInterval(doorsInterval) clearInterval(doorsInterval)
@ -181,9 +187,6 @@ async function fetchDoors() {
apiError.current = "networkError" apiError.current = "networkError"
} }
} }
} finally {
loading.doors = false
refresh()
} }
} }

View file

@ -33,6 +33,8 @@ export const ui = {
"networkError.title": "Network error", "networkError.title": "Network error",
"networkError.description": `Please check your network connection.`, "networkError.description": `Please check your network connection.`,
"loadingDoors": 'Loading doors', "loadingDoors": 'Loading doors',
"chat.dooris": "DOORIS",
"chat.user": "You",
}, },
de: { de: {
"dooris": "TüRIS <span class='text-neutral-content/50 text-xs'>(DOORIS)</span>", "dooris": "TüRIS <span class='text-neutral-content/50 text-xs'>(DOORIS)</span>",
@ -61,6 +63,8 @@ export const ui = {
"networkError.title": "Netzwerkfehler", "networkError.title": "Netzwerkfehler",
"networkError.description": `Bitte überprüfe deine Internetverbindung.`, "networkError.description": `Bitte überprüfe deine Internetverbindung.`,
"loadingDoors": 'Lade Türen', "loadingDoors": 'Lade Türen',
"chat.dooris": "TüRIS",
"chat.user": "Du",
}, },
uwu: { uwu: {
"dooris": "uwu <span class='opacity-50 text-xs'>(DOORIS)</span>", "dooris": "uwu <span class='opacity-50 text-xs'>(DOORIS)</span>",
@ -89,5 +93,7 @@ export const ui = {
"networkError.title": "network is not worky", "networkError.title": "network is not worky",
"networkError.description": `pwease connect to internet`, "networkError.description": `pwease connect to internet`,
"loadingDoors": 'loading portals', "loadingDoors": 'loading portals',
"chat.dooris": "uwu",
"chat.user": "my fren :3",
}, },
} as const } as const

View file

@ -10,6 +10,12 @@
background-size: 300%; background-size: 300%;
background-position-x: 100%; background-position-x: 100%;
animation: shimmer 2s infinite linear; 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 { @keyframes shimmer {
@ -17,3 +23,28 @@
background-position-x: 0; 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;
}
}
}