Remove max height from image in chat and change breakpoint for mobile
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 14s

This commit is contained in:
2026-04-06 22:55:46 +01:00
parent 6029066a94
commit 282454140f
2 changed files with 148 additions and 122 deletions

View File

@@ -19,177 +19,203 @@ const SCROLL_THRESHOLD = 100;
let resizeObserver = null; let resizeObserver = null;
function scrollToBottom() { function scrollToBottom() {
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight; messagesContainer.value.scrollTop =
} messagesContainer.value.scrollHeight;
}
} }
function scrollToBottomIfNear() { function scrollToBottomIfNear() {
if (isNearBottom.value) { if (isNearBottom.value) {
scrollToBottom(); scrollToBottom();
} }
} }
function onScroll() { function onScroll() {
if (!messagesContainer.value) return; if (!messagesContainer.value) return;
const { scrollHeight, scrollTop, clientHeight } = messagesContainer.value; const { scrollHeight, scrollTop, clientHeight } = messagesContainer.value;
isNearBottom.value = scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD; isNearBottom.value =
scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;
} }
function goToBottom() { function goToBottom() {
isNearBottom.value = true; isNearBottom.value = true;
scrollToBottom(); scrollToBottom();
} }
watch( watch(
() => messages.value.length, () => messages.value.length,
() => { () => {
nextTick(scrollToBottomIfNear); nextTick(scrollToBottomIfNear);
}, },
); );
function sendMessage() { function sendMessage() {
const text = messageInput.value.trim(); const text = messageInput.value.trim();
if (!text) return; if (!text) return;
isNearBottom.value = true; isNearBottom.value = true;
messagesStore.sendMessage(text); messagesStore.sendMessage(text);
messageInput.value = ""; messageInput.value = "";
} }
async function onFileSelected(e) { async function onFileSelected(e) {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
isNearBottom.value = true; isNearBottom.value = true;
await messagesStore.uploadAndSendFile(file); await messagesStore.uploadAndSendFile(file);
fileInput.value.value = ""; fileInput.value.value = "";
} }
function isImageUrl(url) { function isImageUrl(url) {
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url); return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
} }
function isVideoUrl(url) { function isVideoUrl(url) {
return /\.(mp4|webm|ogg|mov)$/i.test(url); return /\.(mp4|webm|ogg|mov)$/i.test(url);
} }
function isSafeFileUrl(url) { function isSafeFileUrl(url) {
return typeof url === "string" && url.startsWith("/uploads/"); return typeof url === "string" && url.startsWith("/uploads/");
} }
const urlRegex = /(https?:\/\/[^\s<]+)/g; const urlRegex = /(https?:\/\/[^\s<]+)/g;
function parseMessageParts(text) { function parseMessageParts(text) {
const parts = []; const parts = [];
let lastIndex = 0; let lastIndex = 0;
let match; let match;
while ((match = urlRegex.exec(text)) !== null) { while ((match = urlRegex.exec(text)) !== null) {
if (match.index > lastIndex) { if (match.index > lastIndex) {
parts.push({ parts.push({
type: "text", type: "text",
value: text.slice(lastIndex, match.index), value: text.slice(lastIndex, match.index),
}); });
}
parts.push({ type: "link", value: match[1] });
lastIndex = urlRegex.lastIndex;
} }
parts.push({ type: "link", value: match[1] }); if (lastIndex < text.length) {
lastIndex = urlRegex.lastIndex; parts.push({ type: "text", value: text.slice(lastIndex) });
} }
if (lastIndex < text.length) { return parts;
parts.push({ type: "text", value: text.slice(lastIndex) });
}
return parts;
} }
onMounted(() => { onMounted(() => {
messagesStore.connect(); messagesStore.connect();
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.addEventListener("scroll", onScroll, { passive: true }); messagesContainer.value.addEventListener("scroll", onScroll, {
} passive: true,
});
}
if (messagesInner.value) { if (messagesInner.value) {
resizeObserver = new ResizeObserver(scrollToBottomIfNear); resizeObserver = new ResizeObserver(scrollToBottomIfNear);
resizeObserver.observe(messagesInner.value); resizeObserver.observe(messagesInner.value);
} }
scrollToBottom(); scrollToBottom();
}); });
onUnmounted(() => { onUnmounted(() => {
messagesStore.disconnect(); messagesStore.disconnect();
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.removeEventListener("scroll", onScroll); messagesContainer.value.removeEventListener("scroll", onScroll);
} }
if (resizeObserver) { if (resizeObserver) {
resizeObserver.disconnect(); resizeObserver.disconnect();
resizeObserver = null; resizeObserver = null;
} }
}); });
</script> </script>
<template> <template>
<div class="chat-root flex-col flex min-h-0"> <div class="chat-root flex-col flex min-h-0">
<Header>Chat</Header> <Header>Chat</Header>
<div ref="messagesContainer" class="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2 min-w-0"> <div
<div ref="messagesInner"> ref="messagesContainer"
<p v-for="message in messages" :key="message.id" class="break-words min-w-0 w-full"> class="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2 min-w-0"
<span class="text-tertiary">{{ message.authorId }}:</span>
<template
v-for="(part, i) in parseMessageParts(message.text || '')"
:key="i"
>
<Link
v-if="part.type === 'link'"
bare
:href="part.value"
target="_blank"
class="text-primary underline break-all"
>{{ part.value }}</Link
>
<span v-else>{{ part.value }}</span>
</template>
<template v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)">
<img
v-if="isImageUrl(message.fileUrl)"
:src="message.fileUrl"
alt="Uploaded image"
loading="lazy"
class="w-full max-w-full max-h-48 rounded block"
/>
<video
v-else-if="isVideoUrl(message.fileUrl)"
:src="message.fileUrl"
controls
preload="none"
class="w-full max-w-full max-h-48 rounded block"
/>
<Link v-else bare :href="message.fileUrl" target="_blank" class="underline break-all">{{
message.fileUrl.split("/").pop()
}}</Link>
</template>
</p>
</div>
</div>
<div>
<input v-model="messageInput" @keyup.enter="sendMessage" aria-label="Chat message" />
<input
ref="fileInput"
type="file"
class="hidden"
@change="onFileSelected"
/>
<div class="flex gap-2">
<Button class="flex-1" @click="sendMessage">Send</Button>
<Button
v-if="authStore.user.admin"
class="flex-1"
@click="fileInput.click()"
>Attach</Button
> >
<Button v-if="!isNearBottom" class="flex-1" @click="goToBottom">Bottom</Button> <div ref="messagesInner">
</div> <p
v-for="message in messages"
:key="message.id"
class="break-words min-w-0 w-full"
>
<span class="text-tertiary">{{ message.authorId }}:</span>
<template
v-for="(part, i) in parseMessageParts(
message.text || '',
)"
:key="i"
>
<Link
v-if="part.type === 'link'"
bare
:href="part.value"
target="_blank"
class="text-primary underline break-all"
>{{ part.value }}</Link
>
<span v-else>{{ part.value }}</span>
</template>
<template
v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)"
>
<img
v-if="isImageUrl(message.fileUrl)"
:src="message.fileUrl"
alt="Uploaded image"
loading="lazy"
class="w-full max-w-full rounded block"
/>
<video
v-else-if="isVideoUrl(message.fileUrl)"
:src="message.fileUrl"
controls
preload="none"
class="w-full max-w-full max-h-48 rounded block"
/>
<Link
v-else
bare
:href="message.fileUrl"
target="_blank"
class="underline break-all"
>{{ message.fileUrl.split("/").pop() }}</Link
>
</template>
</p>
</div>
</div>
<div>
<input
v-model="messageInput"
@keyup.enter="sendMessage"
aria-label="Chat message"
/>
<input
ref="fileInput"
type="file"
class="hidden"
@change="onFileSelected"
/>
<div class="flex gap-2">
<Button class="flex-1" @click="sendMessage">Send</Button>
<Button
v-if="authStore.user.admin"
class="flex-1"
@click="fileInput.click()"
>Attach</Button
>
<Button v-if="!isNearBottom" class="flex-1" @click="goToBottom"
>Bottom</Button
>
</div>
</div>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>

View File

@@ -201,7 +201,7 @@ import Steam from "./Steam.vue";
/* } */ /* } */
} }
@media (max-width: 509px) { @media (max-width: 700px) {
.homeGrid { .homeGrid {
border-image: none; border-image: none;
border-width: 0; border-width: 0;