Files
web_server/nginx/vue/src/components/util/Chat.vue
Adam French 6dddcd4d7a Replace raw anchor tags with Link component across views
Use Link component in Chat, CommitHistory, Stamps, Demoman, and fix Navbar to use span instead of nested anchors. Also updates Navbar inHome check for /stp route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:43:32 +00:00

151 lines
4.1 KiB
Vue

<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from "vue";
import Button from "@/components/input/Button.vue";
import { useMessagesStore } from "@/stores/messages";
import { useAuthStore } from "@/stores/auth";
import Header from "@/components/text/Header.vue";
import Link from "@/components/text/Link.vue";
const messagesStore = useMessagesStore();
const authStore = useAuthStore();
const messages = computed(() => messagesStore.messages);
const messageInput = ref("");
const messagesContainer = ref(null);
const fileInput = ref(null);
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
});
}
watch(messages, scrollToBottom, { deep: true });
function sendMessage() {
const text = messageInput.value.trim();
if (!text) return;
messagesStore.sendMessage(text);
messageInput.value = "";
}
async function onFileSelected(e) {
const file = e.target.files[0];
if (!file) return;
await messagesStore.uploadAndSendFile(file);
fileInput.value.value = "";
}
function isImageUrl(url) {
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
}
function isVideoUrl(url) {
return /\.(mp4|webm|ogg|mov)$/i.test(url);
}
function isSafeFileUrl(url) {
return typeof url === "string" && url.startsWith("/uploads/");
}
const urlRegex = /(https?:\/\/[^\s<]+)/g;
function parseMessageParts(text) {
const parts = [];
let lastIndex = 0;
let match;
while ((match = urlRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({
type: "text",
value: text.slice(lastIndex, match.index),
});
}
parts.push({ type: "link", value: match[1] });
lastIndex = urlRegex.lastIndex;
}
if (lastIndex < text.length) {
parts.push({ type: "text", value: text.slice(lastIndex) });
}
return parts;
}
onMounted(() => {
messagesStore.connect();
});
onUnmounted(() => {
messagesStore.disconnect();
});
</script>
<template>
<div class="chat-root flex-col flex min-h-0">
<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">
<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"
class="w-full max-w-full max-h-48 rounded block"
@load="scrollToBottom"
/>
<video
v-else-if="isVideoUrl(message.fileUrl)"
:src="message.fileUrl"
controls
class="w-full max-w-full max-h-48 rounded block"
@loadedmetadata="scrollToBottom"
/>
<Link v-else bare :href="message.fileUrl" target="_blank" class="underline break-all">{{
message.fileUrl.split("/").pop()
}}</Link>
</template>
</p>
</div>
<div>
<input v-model="messageInput" @keyup.enter="sendMessage" />
<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 class="flex-1" @click="scrollToBottom">Bottom</Button>
</div>
</div>
</div>
</template>
<style scoped>
@media (max-width: 850px) {
.chat-root {
max-height: 400px;
}
}
</style>