Merge branch 'feature/deconstruct-chat'

This commit is contained in:
Daan Meijer 2026-06-23 11:54:45 +02:00
commit 807a260cf6
4 changed files with 569 additions and 236 deletions

View File

@ -1,9 +1,11 @@
<script setup lang="ts">
import { useForm, usePage, router } from '@inertiajs/vue3';
import { usePage } from '@inertiajs/vue3';
import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue';
import { Paperclip, Info } from '@lucide/vue';
import { ref, computed, watch } from 'vue';
import { route } from 'ziggy-js';
import ChatInput from './chat/ChatInput.vue';
import ChatSystemMessage from './chat/ChatSystemMessage.vue';
import ChatUserMessage from './chat/ChatUserMessage.vue';
const props = withDefaults(
defineProps<{
@ -131,30 +133,10 @@ if (!echoIsConfigured()) {
});
}
const fileInput = ref<HTMLInputElement | null>(null);
const form = useForm({
content: '',
media: [] as File[],
});
useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => {
messages.value.push(e.message);
});
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 participantsById = computed(() => {
const list = props.participants || [];
@ -175,83 +157,6 @@ const participantsById = computed(() => {
);
});
function parseMessageContent(message: {
content: string;
subject_id?: number | null;
subject_type?: string | null;
subject?: any;
}) {
let content = 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 = participantsById.value[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 (message.subject_id && message.subject_type) {
if (
message.subject_type === 'mutation' ||
message.subject_type === 'ledger'
) {
const ledgerId =
message.subject_type === 'mutation'
? message.subject?.ledger_id
: message.subject?.id;
const ledgerName =
message.subject_type === 'mutation'
? message.subject?.ledger?.name
: 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;
}
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);
}
const currentUser = computed(() => usePage().props.auth?.user);
function isOwnMessage(messageUserId: number | null): boolean {
@ -262,19 +167,6 @@ function isOwnMessage(messageUserId: number | null): boolean {
return currentUser.value && currentUser.value.id === messageUserId;
}
function submit() {
form.post(route('chats.messages.store', props.chat.id), {
preserveScroll: true,
onSuccess: () => {
form.reset();
if (fileInput.value) {
fileInput.value.value = '';
}
},
});
}
// Lightbox Modal state
const activeLightboxUrl = ref<string | null>(null);
const activeLightboxType = ref<'image' | 'video' | null>(null);
@ -314,138 +206,29 @@ function closeLightbox() {
]"
>
<!-- Standard User Chat Message -->
<template v-if="message.user">
<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="parseMessageContent(message)"></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="openLightbox(item.url, item.mime_type)"
/>
<div
v-else-if="item.mime_type.startsWith('video/')"
class="relative cursor-pointer transition-opacity hover:opacity-90"
@click="openLightbox(item.url, item.mime_type)"
>
<video
:src="item.url"
class="c-chat__video"
></video>
<div class="c-chat__play-overlay"></div>
</div>
</div>
</div>
</template>
<ChatUserMessage
v-if="message.user"
:message="message"
:participants-by-id="participantsById"
:dynamic-id="dynamicId"
@open-lightbox="openLightbox"
/>
<!-- Subtle Activity Log System Message -->
<template v-else>
<div class="c-chat__system-inner">
<Info class="c-chat__system-icon" />
<span
class="c-chat__system-text"
v-html="parseMessageContent(message)"
></span>
<span
class="c-chat__system-time"
:title="formatTimestamp(message.created_at).full"
>
{{ formatTimestamp(message.created_at).time }}
</span>
</div>
</template>
<ChatSystemMessage
v-else
:message="message"
:participants-by-id="participantsById"
:dynamic-id="dynamicId"
/>
</div>
<div v-if="messages.length === 0" class="c-chat__empty">
No messages yet.
</div>
</div>
<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>
<!-- Cohesive Chat input Form -->
<ChatInput :chat-id="chat.id" />
<!-- Gorgeous Dark Lightbox Modal -->
<div v-if="activeLightboxUrl" class="c-lightbox" @click="closeLightbox">
@ -467,3 +250,183 @@ function closeLightbox() {
</div>
</div>
</template>
<style scoped>
@reference "../../css/app.css";
.c-chat {
@apply flex flex-col h-[500px];
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.c-chat__title {
@apply p-4 font-bold border-b text-sm;
border-color: var(--border);
color: var(--foreground);
}
.c-chat__list {
@apply flex-1 overflow-y-auto p-4 space-y-4;
}
.c-chat__load-more {
@apply flex justify-center py-2;
}
.c-chat__load-more-btn {
@apply text-xs font-semibold text-blue-500 hover:underline;
}
.c-chat__message {
@apply max-w-[75%] p-3 rounded-lg flex flex-col gap-1;
}
.c-chat__message--own {
@apply self-end;
background-color: var(--primary);
color: var(--primary-foreground);
border-bottom-right-radius: 0;
.c-chat__message-author {
@apply hidden;
}
.c-chat__message-time {
@apply text-blue-100;
}
.c-chat__message-text {
color: var(--primary-foreground);
}
}
.c-chat__message--other {
@apply self-start;
background-color: var(--muted);
color: var(--muted-foreground);
border-bottom-left-radius: 0;
}
.c-chat__message--system {
@apply self-center max-w-full w-full bg-transparent border-0 p-0 text-center gap-0;
}
.c-chat__message-header {
@apply flex items-baseline gap-2 mb-1;
}
.c-chat__message-author {
@apply font-semibold text-xs;
}
.c-chat__message-time {
@apply text-[10px];
color: var(--muted-foreground);
}
.c-chat__message-text {
@apply text-sm break-words whitespace-pre-wrap leading-relaxed;
}
.c-chat__message-media {
@apply mt-2 flex flex-wrap gap-2;
}
.c-chat__media-item {
@apply max-w-[120px] overflow-hidden rounded border border-black dark:border-gray-600 bg-black;
}
.c-chat__image {
@apply h-auto max-h-[80px] w-full object-cover;
}
.c-chat__video {
@apply h-auto max-h-[80px] w-full;
}
.c-chat__play-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-lg font-bold text-white;
}
.c-chat__system-inner {
@apply inline-flex items-center gap-2 bg-neutral-100 dark:bg-neutral-900/30 px-3 py-1 rounded-full text-xs text-neutral-500 dark:text-neutral-400;
}
.c-chat__system-icon {
@apply size-3.5;
}
.c-chat__system-text {
@apply font-medium leading-relaxed;
}
.c-chat__system-time {
@apply text-[10px] opacity-75 ml-1;
}
.c-chat__empty {
@apply text-center text-xs py-8;
color: var(--muted-foreground);
}
.c-chat__form {
@apply p-4 border-t flex flex-col gap-2 bg-neutral-50 dark:bg-neutral-900/10;
border-color: var(--border);
}
.c-chat__form-group {
@apply relative flex flex-col gap-1;
}
.c-chat__label {
@apply sr-only;
}
.c-chat__textarea {
@apply w-full rounded border p-2 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-800;
color: var(--foreground);
border-color: var(--border);
}
.c-chat__error {
@apply text-xs text-red-500 mt-1;
}
.c-chat__attachment-container {
@apply flex items-center mt-1;
}
.c-chat__attach-btn {
@apply inline-flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors;
}
.c-chat__attach-icon {
@apply size-3.5;
}
.c-chat__preview-list {
@apply mt-2 flex flex-wrap gap-2;
}
.c-chat__preview-item {
@apply inline-flex items-center gap-2 bg-neutral-100 dark:bg-neutral-900/50 px-2 py-1 rounded text-xs;
}
.c-chat__preview-name {
@apply max-w-[150px] truncate text-neutral-600 dark:text-neutral-400;
}
.c-chat__preview-remove {
@apply text-neutral-400 hover:text-red-500 transition-colors;
}
.c-chat__submit-box {
@apply flex justify-end mt-1;
}
.c-chat__button {
@apply inline-flex items-center justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
</style>

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>

View File

@ -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>

View File

@ -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>