refactor: Deconstruct Chat.vue into clean single-responsibility sub-components

This commit is contained in:
Daan Meijer
2026-06-23 11:39:45 +02:00
parent d68fc33bcb
commit 5aced64669
4 changed files with 569 additions and 236 deletions
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Paperclip } from '@lucide/vue';
import { ref } from 'vue';
import { route } from 'ziggy-js';
const props = defineProps<{
chatId: number;
}>();
const fileInput = ref<HTMLInputElement | null>(null);
const form = useForm({
content: '',
media: [] as File[],
});
function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files) {
for (let i = 0; i < files.length; i++) {
form.media.push(files[i]);
}
}
}
function removeFile(index: number) {
form.media.splice(index, 1);
}
function submit() {
form.post(route('chats.messages.store', props.chatId), {
preserveScroll: true,
onSuccess: () => {
form.reset();
if (fileInput.value) {
fileInput.value.value = '';
}
},
});
}
</script>
<template>
<form @submit.prevent="submit" class="c-chat__form">
<div class="c-chat__form-group">
<label for="content" class="c-chat__label">Message</label>
<textarea
v-model="form.content"
id="content"
rows="3"
class="c-chat__textarea"
placeholder="Type a message... (Press Enter to send, Shift+Enter for newline)"
@keydown.enter.exact.prevent="submit"
></textarea>
<div v-if="form.errors.content" class="c-chat__error">
{{ form.errors.content }}
</div>
<!-- Attachment Button & Hidden Input -->
<div class="c-chat__attachment-container">
<button
type="button"
@click="fileInput?.click()"
class="c-chat__attach-btn"
>
<Paperclip class="c-chat__attach-icon" />
Attach Photos/Videos
</button>
<input
ref="fileInput"
type="file"
multiple
accept="image/*,video/*"
class="hidden"
@change="handleFileChange"
/>
</div>
<!-- Previews List -->
<div v-if="form.media.length > 0" class="c-chat__preview-list">
<div
v-for="(file, index) in form.media"
:key="index"
class="c-chat__preview-item"
>
<span class="c-chat__preview-name">{{
file.name
}}</span>
<button
type="button"
@click="removeFile(index)"
class="c-chat__preview-remove"
>
</button>
</div>
</div>
</div>
<div class="c-chat__submit-box">
<button
type="submit"
:disabled="form.processing"
class="c-chat__button"
>
Send
</button>
</div>
</form>
</template>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed } from 'vue';
import { route } from 'ziggy-js';
import { Info } from '@lucide/vue';
const props = defineProps<{
message: {
id: number;
content: string;
created_at: string;
subject_id?: number | null;
subject_type?: string | null;
subject?: any;
};
participantsById: Record<
number,
{
id: number;
name: string;
pivot?: { display_name: string | null } | null;
}
>;
dynamicId: string;
}>();
function formatTimestamp(isoString: string): { full: string; time: string } {
const date = new Date(isoString);
return {
full: date.toLocaleString(),
time: date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
};
}
const parsedContent = computed(() => {
let content = props.message.content;
// 1. Replace <user:id> placeholders with links to their dynamic profile
const userRegex = /<user:(\d+)>/g;
content = content.replace(userRegex, (match, userId) => {
const user = props.participantsById[Number(userId)];
if (user) {
const url = route('dynamics.users.show', [props.dynamicId, Number(userId)]);
return `<a href="${url}" class="c-chat__user-link font-semibold text-blue-500 hover:underline">${
user.pivot?.display_name ?? user.name
}</a>`;
}
return `User #${userId}`;
});
// 2. Link subjects if found in the text
if (props.message.subject_id && props.message.subject_type) {
if (
props.message.subject_type === 'mutation' ||
props.message.subject_type === 'ledger'
) {
const ledgerId =
props.message.subject_type === 'mutation'
? props.message.subject?.ledger_id
: props.message.subject?.id;
const ledgerName =
props.message.subject_type === 'mutation'
? props.message.subject?.ledger?.name
: props.message.subject?.name;
if (ledgerId && ledgerName) {
const ledgerUrl = route('dynamics.ledgers.show', [
props.dynamicId,
ledgerId,
]);
const escapedName = ledgerName.replace(
/[-\/\\^$*+?.()|[\]{}]/g,
'\\$&',
);
const nameRegex = new RegExp(`"${escapedName}"`, 'g');
content = content.replace(
nameRegex,
`"<a href="${ledgerUrl}" class="c-chat__subject-link font-semibold text-blue-500 hover:underline">${ledgerName}</a>"`,
);
}
}
}
return content;
});
</script>
<template>
<div class="c-chat__system-inner">
<Info class="c-chat__system-icon" />
<span
class="c-chat__system-text"
v-html="parsedContent"
></span>
<span
class="c-chat__system-time"
:title="formatTimestamp(message.created_at).full"
>
{{ formatTimestamp(message.created_at).time }}
</span>
</div>
</template>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed } from 'vue';
import { route } from 'ziggy-js';
const props = defineProps<{
message: {
id: number;
user: { id: number; name: string } | null;
content: string;
created_at: string;
subject_id?: number | null;
subject_type?: string | null;
subject?: any;
media?: Array<{
id: number;
url: string;
file_name: string;
mime_type: string;
}>;
};
participantsById: Record<
number,
{
id: number;
name: string;
pivot?: { display_name: string | null } | null;
}
>;
dynamicId: string;
}>();
const emit = defineEmits<{
(e: 'open-lightbox', url: string, mimeType: string): void;
}>();
function formatTimestamp(isoString: string): { full: string; time: string } {
const date = new Date(isoString);
return {
full: date.toLocaleString(),
time: date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
};
}
const parsedContent = computed(() => {
let content = props.message.content;
// 1. Replace <user:id> placeholders with links to their dynamic profile
const userRegex = /<user:(\d+)>/g;
content = content.replace(userRegex, (match, userId) => {
const user = props.participantsById[Number(userId)];
if (user) {
const url = route('dynamics.users.show', [props.dynamicId, Number(userId)]);
return `<a href="${url}" class="c-chat__user-link font-semibold text-blue-500 hover:underline">${
user.pivot?.display_name ?? user.name
}</a>`;
}
return `User #${userId}`;
});
// 2. Link subjects if found in the text
if (props.message.subject_id && props.message.subject_type) {
if (
props.message.subject_type === 'mutation' ||
props.message.subject_type === 'ledger'
) {
const ledgerId =
props.message.subject_type === 'mutation'
? props.message.subject?.ledger_id
: props.message.subject?.id;
const ledgerName =
props.message.subject_type === 'mutation'
? props.message.subject?.ledger?.name
: props.message.subject?.name;
if (ledgerId && ledgerName) {
const ledgerUrl = route('dynamics.ledgers.show', [
props.dynamicId,
ledgerId,
]);
const escapedName = ledgerName.replace(
/[-\/\\^$*+?.()|[\]{}]/g,
'\\$&',
);
const nameRegex = new RegExp(`"${escapedName}"`, 'g');
content = content.replace(
nameRegex,
`"<a href="${ledgerUrl}" class="c-chat__subject-link font-semibold text-blue-500 hover:underline">${ledgerName}</a>"`,
);
}
}
}
return content;
});
</script>
<template>
<div>
<div class="c-chat__message-header">
<span class="c-chat__message-author">{{
message.user?.name
}}</span>
<span
class="c-chat__message-time"
:title="formatTimestamp(message.created_at).full"
>
{{ formatTimestamp(message.created_at).time }}
</span>
</div>
<p class="c-chat__message-text" v-html="parsedContent"></p>
<!-- Attached Media Display -->
<div
v-if="message.media && message.media.length > 0"
class="c-chat__message-media"
>
<div
v-for="item in message.media"
:key="item.id"
class="c-chat__media-item"
>
<img
v-if="item.mime_type.startsWith('image/')"
:src="item.url"
:alt="item.file_name"
class="c-chat__image cursor-pointer transition-opacity hover:opacity-90"
@click="emit('open-lightbox', item.url, item.mime_type)"
/>
<div
v-else-if="item.mime_type.startsWith('video/')"
class="relative cursor-pointer transition-opacity hover:opacity-90"
@click="emit('open-lightbox', item.url, item.mime_type)"
>
<video
:src="item.url"
class="c-chat__video"
></video>
<div class="c-chat__play-overlay"></div>
</div>
</div>
</div>
</div>
</template>