feat: Implement chat pagination, participant single page with mutations, and fix user link substitutions

This commit is contained in:
Daan Meijer 2026-06-17 22:55:11 +02:00
parent 11df4ef55c
commit ed16d5dcda
12 changed files with 268 additions and 43 deletions

View File

@ -52,17 +52,7 @@ class DynamicController extends Controller
$activityService->updateCursor($request->user(), $dynamic);
$dynamic->load([
'ledgers.media',
'participants' => fn($query) => $query->withPivot('display_name'),
'chat.messages.user',
'chat.messages.media',
'chat.messages.subject' => function ($morphTo) {
$morphTo->morphWith([
\App\Models\Mutation::class => ['ledger'],
]);
}
]);
$dynamic->load(['ledgers.media', 'participants', 'chat']);
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
@ -72,9 +62,17 @@ class DynamicController extends Controller
return Inertia::render('Dynamics/Show', [
'dynamic' => $dynamic,
'isOwner' => $isOwner,
'messages' => $dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20),
]);
}
public function messages(Request $request, Dynamic $dynamic)
{
$this->authorize('view', $dynamic);
return $dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20);
}
/**
* Show the form for editing the specified resource.
*/

View File

@ -64,7 +64,7 @@ class LedgerController extends Controller
$activityService->updateCursor($request->user(), $ledger);
$dynamic->load(['chat', 'participants' => fn($query) => $query->withPivot('display_name')]);
$dynamic->load('chat', 'participants');
$ledger->load([
'media',
@ -91,9 +91,17 @@ class LedgerController extends Controller
'dynamic' => $dynamic,
'ledger' => $ledger,
'isOwner' => $isOwner,
'messages' => $dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20),
]);
}
public function messages(Request $request, Dynamic $dynamic, Ledger $ledger)
{
$this->authorize('view', $ledger);
return $dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(20);
}
/**
* Show the form for editing the specified resource.
*/

View File

@ -4,11 +4,29 @@ namespace App\Http\Controllers;
use App\Models\Dynamic;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ParticipantController extends Controller
{
use AuthorizesRequests;
public function update(Request $request, Dynamic $dynamic)
{
$request->validate([
'display_name' => ['required', 'string', 'max:255'],
]);
$participant = $dynamic->participants()->where('user_id', $request->user()->id)->firstOrFail();
$dynamic->participants()->updateExistingPivot($participant->id, [
'display_name' => $request->input('display_name'),
]);
return redirect()->back()->with('success', 'Display name updated successfully!');
}
public function show(Request $request, Dynamic $dynamic, User $user)
{
// Ensure both the authenticated user and the target user are in the dynamic
@ -25,7 +43,7 @@ class ParticipantController extends Controller
->take(10)
->get();
return Inertia::render('Participants/Show', [
return Inertia::render('Dynamics/Participants/Show', [
'dynamic' => $dynamic,
'participant' => [
'id' => $user->id,
@ -37,18 +55,4 @@ class ParticipantController extends Controller
]);
}
public function update(Request $request, Dynamic $dynamic)
{
$request->validate([
'display_name' => ['required', 'string', 'max:255'],
]);
$participant = $dynamic->participants()->where('user_id', $request->user()->id)->firstOrFail();
$dynamic->participants()->updateExistingPivot($participant->id, [
'display_name' => $request->input('display_name'),
]);
return redirect()->back()->with('success', 'Display name updated successfully!');
}
}

View File

@ -10,6 +10,19 @@
.c-chat__list {
@apply mt-4 flex flex-col gap-3;
.c-chat__load-more {
@apply mb-4 text-center;
.c-chat__load-more-btn {
@apply text-sm font-semibold;
color: var(--primary);
&:hover {
text-decoration: underline;
}
}
}
.c-chat__message {
@apply overflow-hidden p-4 shadow-sm sm:rounded-lg;
border: 1px solid var(--border);

View File

@ -1,15 +1,15 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import { useForm, usePage, router } from '@inertiajs/vue3';
import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue';
import { route } from 'ziggy-js';
import { Paperclip, Info } from '@lucide/vue';
import { ref, computed } from 'vue';
import { route } from 'ziggy-js';
const props = withDefaults(
defineProps<{
chat: {
id: number;
messages: Array<{
messages?: Array<{
id: number;
user: { id: number; name: string } | null;
content: string;
@ -33,12 +33,36 @@ const props = withDefaults(
} | null;
}>;
dynamicId: number;
initialMessages: {
data: Array<any>;
next_page_url: string | null;
};
}>(),
{
participants: () => [],
}
);
const messages = ref(props.initialMessages.data.reverse());
const nextPageUrl = ref(props.initialMessages.next_page_url);
function loadMoreMessages() {
if (!nextPageUrl.value) {
return;
}
router.get(nextPageUrl.value, {}, {
preserveState: true,
preserveScroll: true,
only: ['messages'],
onSuccess: (page) => {
const newMessages = page.props.messages as { data: Array<any>; next_page_url: string | null };
messages.value = [...newMessages.data.reverse(), ...messages.value];
nextPageUrl.value = newMessages.next_page_url;
},
});
}
if (!echoIsConfigured()) {
configureEcho({
broadcaster: 'reverb',
@ -63,11 +87,12 @@ const form = useForm({
});
useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => {
props.chat.messages.push(e.message);
messages.value.push(e.message);
});
const participantsById = computed(() => {
return props.participants.reduce(
const list = props.participants || [];
return list.reduce(
(acc, p) => {
acc[p.id] = p;
return acc;
@ -130,7 +155,7 @@ function parseMessageContent(message: {
/[-\/\\^$*+?.()|[\]{}]/g,
'\\$&',
);
const nameRegex = new RegExp(`"${escapedName}"`, 'g');
content = content.replace(
nameRegex,
@ -197,17 +222,20 @@ function closeLightbox() {
<div class="c-chat">
<h4 class="c-chat__title">Chat</h4>
<div class="c-chat__list">
<div v-if="nextPageUrl" class="c-chat__load-more">
<button @click="loadMoreMessages" class="c-chat__load-more-btn">
Load More
</button>
</div>
<div
v-for="message in chat.messages"
v-for="message in messages"
:key="message.id"
:class="[
'c-chat__message',
{
'c-chat__message--system': message.user === null,
'c-chat__message--own': isOwnMessage(message.user?.id),
'c-chat__message--other': message.user !== null && !isOwnMessage(
message.user?.id,
),
'c-chat__message--other': message.user !== null && !isOwnMessage(message.user?.id)
},
]"
>
@ -276,7 +304,7 @@ function closeLightbox() {
</div>
</template>
</div>
<div v-if="chat.messages.length === 0" class="c-chat__empty">
<div v-if="messages.length === 0" class="c-chat__empty">
No messages yet.
</div>
</div>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
const props = defineProps<{
message: {
id: number;
user: { id: number; name: string } | null;
content: string;
created_at: string;
media?: Array<{
id: number;
url: string;
file_name: string;
mime_type: string;
}>;
};
}>();
const processedContent = computed(() => {
return props.message.content.replace(/<user:(\d+)>/g, (match, userId) => {
// This is a placeholder for a more robust user lookup
return `<a href="${route('users.show', userId)}" class="text-blue-500 hover:underline">@user${userId}</a>`;
});
});
</script>
<template>
<div v-html="processedContent"></div>
</template>

View File

@ -1,5 +1,9 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
defineProps<{
dynamicId: number;
participants: Array<{
id: number;
name: string;
@ -19,7 +23,12 @@ defineProps<{
:key="participant.id"
class="c-participants-list__item"
>
{{ participant.pivot.display_name ?? participant.name }}
<Link
:href="route('dynamics.users.show', [dynamicId, participant.id])"
class="block"
>
{{ participant.pivot.display_name ?? participant.name }}
</Link>
</li>
</ul>
</div>

View File

@ -0,0 +1,121 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
participant: {
id: number;
name: string;
display_name: string | null;
role: string;
};
mutations: Array<{
id: number;
amount: number;
description: string;
status: string;
created_at: string;
ledger: { id: number; name: string };
}>;
}>();
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
name: props.participant.display_name ?? props.participant.name,
href: route('dynamics.users.show', [props.dynamic.id, props.participant.id]),
},
];
</script>
<template>
<Head :title="participant.display_name ?? participant.name" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="c-participant-show">
<div class="c-participant-show__container">
<h2 class="c-participant-show__title">
Activity for {{ participant.display_name ?? participant.name }} ({{ participant.role.toUpperCase() }}) in {{ dynamic.name }}
</h2>
<div class="c-participant-show__activity-list">
<div
v-for="mutation in mutations"
:key="mutation.id"
class="c-participant-show__activity-item"
>
<Link :href="route('dynamics.ledgers.show', [dynamic.id, mutation.ledger.id])" class="block">
<div class="c-participant-show__activity-meta">
<span class="c-participant-show__activity-time">
{{ new Date(mutation.created_at).toLocaleString() }}
</span>
<span class="font-semibold ml-2" :class="mutation.amount > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
{{ mutation.amount > 0 ? '+' : '' }}{{ mutation.amount }}
</span>
<span class="text-neutral-400 ml-2">on {{ mutation.ledger.name }}</span>
<span class="uppercase text-xs px-1.5 py-0.5 rounded ml-auto" :class="mutation.status === 'approved' ? 'bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400'">
{{ mutation.status }}
</span>
</div>
<p class="c-participant-show__activity-desc">
{{ mutation.description }}
</p>
</Link>
</div>
<div v-if="mutations.length === 0" class="text-neutral-500 text-sm">
No mutations recorded for this participant in this Dynamic yet.
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<style scoped>
@reference "../../../../css/app.css";
.c-participant-show {
@apply py-12;
}
.c-participant-show__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-participant-show__title {
@apply mb-6 text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100;
}
.c-participant-show__activity-list {
@apply space-y-4;
}
.c-participant-show__activity-item {
@apply rounded-lg border p-4 transition-colors hover:bg-neutral-50 dark:border-neutral-800 dark:hover:bg-neutral-900;
background-color: var(--card);
}
.c-participant-show__activity-meta {
@apply mb-1.5 flex items-center text-xs;
}
.c-participant-show__activity-time {
@apply text-neutral-400 dark:text-neutral-500;
}
.c-participant-show__activity-desc {
@apply text-sm text-neutral-600 dark:text-neutral-400 mt-1;
}
</style>

View File

@ -21,6 +21,10 @@ const props = defineProps<{
}>;
};
isOwner: boolean;
messages: {
data: Array<any>;
next_page_url: string | null;
};
}>();
@ -58,7 +62,7 @@ const breadcrumbs = [
</div>
<!-- Dynamic Chat -->
<Chat :chat="dynamic.chat" :participants="dynamic.participants" :dynamic-id="dynamic.id" />
<Chat :chat="dynamic.chat" :initial-messages="messages" :participants="dynamic.participants" :dynamic-id="dynamic.id" />
<!-- Participants Component -->
<ParticipantsList :participants="dynamic.participants" />

View File

@ -4,6 +4,7 @@ import { useEcho } from '@laravel/echo-vue';
import { ref } from 'vue';
import { route } from 'ziggy-js';
import AddMutationForm from '@/components/AddMutationForm.vue';
import Chat from '@/components/Chat.vue';
import MutationList from '@/components/MutationList.vue';
const props = defineProps<{
@ -37,6 +38,10 @@ const props = defineProps<{
}>;
};
isOwner: boolean;
messages: {
data: Array<any>;
next_page_url: string | null;
};
}>();
const breadcrumbs = [
@ -258,6 +263,8 @@ function isOwnerUser(userId: number): boolean {
:ledger-alignment="ledger.alignment"
@open-lightbox="openLightbox"
/>
<Chat :chat="dynamic.chat" :initial-messages="messages" :participants="dynamic.participants" :dynamic-id="dynamic.id" />
</div>
</div>

View File

@ -15,8 +15,10 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('dynamics', DynamicController::class)->except(['edit', 'update']);
Route::get('/dynamics/{dynamic}/settings', [DynamicController::class, 'edit'])->name('dynamics.edit');
Route::patch('/dynamics/{dynamic}/settings', [DynamicController::class, 'update'])->name('dynamics.update');
Route::get('/dynamics/{dynamic}/messages', [DynamicController::class, 'messages'])->name('dynamics.messages');
Route::get('/dynamics/{dynamic}/ledgers/create', [LedgerController::class, 'create'])->name('dynamics.ledgers.create');
Route::get('/dynamics/{dynamic}/ledgers/{ledger}/messages', [LedgerController::class, 'messages'])->name('dynamics.ledgers.messages');
Route::resource('dynamics.ledgers', LedgerController::class)->scoped()->except(['create']);
Route::resource('dynamics.ledgers.predefined-mutations', \App\Http\Controllers\PredefinedMutationController::class)->scoped();

View File

@ -19,7 +19,7 @@ test('authenticated participant can view another participant detail page in dyna
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Participants/Show')
->component('Dynamics/Participants/Show')
->has('dynamic')
->where('participant.id', $participant->id)
->where('participant.name', $participant->name)
@ -86,7 +86,7 @@ test('participant detail page displays their recent mutations in dynamic', funct
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Participants/Show')
->component('Dynamics/Participants/Show')
->where('participant.display_name', 'Bitch')
->has('mutations', 2)
->where('mutations.0.description', 'Infraction 1') // Ordered latest first