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
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 14s
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user