Skip to main content

General

Comments code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/comments.blade.php

@props([
    'post',
])

@php
    /*
    | Full comment section for a post: async add form, live thread, and replies.
    | Driven by Alpine.js — mount once per post show page.
    |
    | Usage:
    |   <x-ui.comments :post="$post" />
    */
    $reactionStates = $post->topLevelComments
        ->flatMap(fn ($comment) => collect([$comment])->merge($comment->replies))
        ->mapWithKeys(fn ($comment) => [
            $comment->uuid => $comment->summarizeReactions(),
        ])
        ->all();

    $reactionsEndpointTemplate = route('comments.reactions.store', ['comment' => '__COMMENT__']);
@endphp

<section
    {{ $attributes->merge(['class' => 'border-t border-gray-200 dark:border-gray-800']) }}
    x-data="commentSection({
        endpoint: '{{ route('comments.store', $post) }}',
        isGuest: {{ auth()->check() ? 'false' : 'true' }},
        initialCount: {{ $post->comments_count }},
        reactionStates: @js($reactionStates),
        reactionsEndpointTemplate: @js($reactionsEndpointTemplate),
    })"
>
    <div class="mx-auto max-w-5xl px-6 py-16 lg:px-8">
        <h2 class="flex items-baseline gap-2 text-lg font-semibold text-gray-900 dark:text-white">
            Comments
            <span class="text-sm font-normal text-gray-400" x-text="`(${count})`"></span>
        </h2>

        <form @submit.prevent="submit()" class="mt-6 space-y-4">
            @guest
                <x-ui.field label="Your name" for="author_name" required>
                    <x-ui.input id="author_name" x-model="form.author_name" ::invalid="!! errors.author_name" placeholder="Jane Doe" />
                    <template x-if="errors.author_name">
                        <p class="mt-1 flex items-center gap-1 text-sm text-red-600 dark:text-red-400">
                            <x-ui.icon name="warning-circle" weight="fill" size="sm" />
                            <span x-text="errors.author_name[0]"></span>
                        </p>
                    </template>
                </x-ui.field>
            @endguest

            <x-ui.field label="Add a comment" for="body" required>
                <x-ui.textarea id="body" x-model="form.body" rows="3" ::invalid="!! errors.body" placeholder="Share your thoughts..." />
                <template x-if="errors.body">
                    <p class="mt-1 flex items-center gap-1 text-sm text-red-600 dark:text-red-400">
                        <x-ui.icon name="warning-circle" weight="fill" size="sm" />
                        <span x-text="errors.body[0]"></span>
                    </p>
                </template>
            </x-ui.field>

            <div class="flex items-center justify-end gap-3">
                <span x-show="justAdded" x-transition class="text-sm text-green-600 dark:text-green-400">Comment posted</span>
                <x-ui.button type="submit" icon="paper-plane-tilt" ::loading="submitting" x-bind:disabled="submitting">
                    Post comment
                </x-ui.button>
            </div>
        </form>

        <div class="mt-10 space-y-2 divide-y divide-gray-200 dark:divide-gray-800">
            <template x-for="comment in comments" :key="comment.uuid">
                <x-ui.comment alpine />
            </template>

            @forelse ($post->topLevelComments as $comment)
                <x-ui.comment :comment="$comment" />
            @empty
                <div x-show="count === 0" x-cloak class="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
                    No comments yet. Be the first to share your thoughts.
                </div>
            @endforelse
        </div>
    </div>
</section>

@push('head')
    <script>
        function commentSection({ endpoint, isGuest, initialCount, reactionStates, reactionsEndpointTemplate }) {
            return {
                endpoint,
                isGuest,
                count: initialCount,
                comments: [],
                newReplies: {},
                reactionStates,
                reactionsEndpointTemplate,
                pickerOpenFor: null,
                submitting: false,
                justAdded: false,
                replyingTo: null,
                errors: {},
                replyErrors: {},
                form: { author_name: '', body: '' },
                replyForm: { author_name: '', body: '' },

                initials(name) {
                    return (name || 'A').split(' ').slice(0, 2).map(p => p.charAt(0).toUpperCase()).join('');
                },

                reactionEndpoint(uuid) {
                    return this.reactionsEndpointTemplate.replace('__COMMENT__', uuid);
                },

                togglePicker(uuid) {
                    this.pickerOpenFor = this.pickerOpenFor === uuid ? null : uuid;
                },

                async toggleReaction(uuid, emoji) {
                    try {
                        const response = await fetch(this.reactionEndpoint(uuid), {
                            method: 'POST',
                            headers: {
                                'Content-Type': 'application/json',
                                'Accept': 'application/json',
                                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
                            },
                            body: JSON.stringify({ emoji }),
                        });

                        if (! response.ok) {
                            throw new Error('Request failed with status ' + response.status);
                        }

                        const data = await response.json();
                        this.reactionStates[uuid] = data.reactions;
                    } catch (error) {
                        // Keep the UI stable if the request fails.
                    }
                },

                toggleReply(uuid) {
                    this.replyingTo = this.replyingTo === uuid ? null : uuid;
                    this.replyErrors = {};
                    this.replyForm = { author_name: '', body: '' };
                },

                cancelReply() {
                    this.replyingTo = null;
                    this.replyErrors = {};
                    this.replyForm = { author_name: '', body: '' };
                },

                async submit(parentUuid = null) {
                    const isReply = parentUuid !== null;
                    this.submitting = true;
                    if (isReply) {
                        this.replyErrors = {};
                    } else {
                        this.errors = {};
                        this.justAdded = false;
                    }

                    const payload = isReply
                        ? { ...this.replyForm, parent_uuid: parentUuid }
                        : { ...this.form };

                    try {
                        const response = await fetch(this.endpoint, {
                            method: 'POST',
                            headers: {
                                'Content-Type': 'application/json',
                                'Accept': 'application/json',
                                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
                            },
                            body: JSON.stringify(payload),
                        });

                        if (response.status === 422) {
                            const data = await response.json();
                            if (isReply) {
                                this.replyErrors = data.errors ?? {};
                            } else {
                                this.errors = data.errors ?? {};
                            }
                            return;
                        }

                        if (! response.ok) {
                            throw new Error('Request failed with status ' + response.status);
                        }

                        const data = await response.json();
                        this.count = data.comments_count;

                        if (isReply) {
                            this.addReply(parentUuid, data.comment);
                            this.cancelReply();
                        } else {
                            this.comments.unshift({ ...data.comment, replies: [] });
                            this.reactionStates[data.comment.uuid] = [];
                            this.form.body = '';
                            this.justAdded = true;
                            setTimeout(() => { this.justAdded = false; }, 2500);
                        }
                    } catch (error) {
                        const message = { body: ['Something went wrong. Please try again.'] };
                        if (isReply) {
                            this.replyErrors = message;
                        } else {
                            this.errors = message;
                        }
                    } finally {
                        this.submitting = false;
                    }
                },

                addReply(parentUuid, reply) {
                    const liveParent = this.comments.find(c => c.uuid === parentUuid);

                    if (liveParent) {
                        liveParent.replies = liveParent.replies || [];
                        liveParent.replies.push(reply);
                        this.reactionStates[reply.uuid] = [];
                        return;
                    }

                    if (! this.newReplies[parentUuid]) {
                        this.newReplies[parentUuid] = [];
                    }
                    this.newReplies[parentUuid].push(reply);
                    this.reactionStates[reply.uuid] = [];
                },
            };
        }
    </script>
@endpush