feat: Implement chat pagination, participant single page with mutations, and fix user link substitutions
This commit is contained in:
parent
11df4ef55c
commit
ed16d5dcda
@ -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.
|
||||
*/
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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!');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
31
resources/js/components/ChatMessage.vue
Normal file
31
resources/js/components/ChatMessage.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
121
resources/js/pages/Dynamics/Participants/Show.vue
Normal file
121
resources/js/pages/Dynamics/Participants/Show.vue
Normal 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>
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user