Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/Http/Controllers/IdeaController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class IdeaController extends Controller
{
public function show(Idea $idea): Response
{
$idea->load(['user', 'voters:id', 'subscribers:id', 'latestStatusUpdate.user']);
$idea->load(['user', 'voters:id', 'subscribers:id', 'latestStatusUpdate.user', 'reactions']);

return inertia('Ideas/Show', [
'idea' => new IdeaResource($idea),
Expand Down
31 changes: 31 additions & 0 deletions app/Http/Controllers/ReactionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreReactionRequest;
use App\Models\Idea;
use Illuminate\Http\RedirectResponse;

class ReactionController extends Controller
{
public function __invoke(StoreReactionRequest $request, Idea $idea): RedirectResponse
{
$user = $request->user();
$emoji = $request->validated('emoji');

$existing = $idea->reactions()
->where('user_id', $user->id)
->where('emoji', $emoji)
->first();

if ($existing) {
$existing->delete();
} else {
$reaction = $idea->reactions()->make(['emoji' => $emoji]);
$reaction->user()->associate($user);
$reaction->save();
}

return redirect()->back();
}
}
20 changes: 20 additions & 0 deletions app/Http/Requests/StoreReactionRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\Http\Requests;

use App\Models\Reaction;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreReactionRequest extends FormRequest
{
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'emoji' => ['required', 'string', Rule::in(Reaction::EMOJIS)],
];
}
}
9 changes: 9 additions & 0 deletions app/Http/Resources/IdeaResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Http\Resources;

use App\Models\Idea;
use App\Models\Reaction;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

Expand All @@ -23,6 +24,14 @@ public function toArray(Request $request): array
'status' => $this->status,
'votes' => $this->votes,
'has_voted' => $user ? $this->voters->contains('id', $user->id) : false,
'reactions' => $this->when(
$this->relationLoaded('reactions'),
fn () => collect(Reaction::EMOJIS)->map(fn (string $emoji) => [
'emoji' => $emoji,
'count' => $this->reactions->where('emoji', $emoji)->count(),
'reacted' => $user ? $this->reactions->where('emoji', $emoji)->contains('user_id', $user->id) : false,
])->values(),
),
'is_subscribed' => $user ? $this->subscribers->contains('id', $user->id) : false,
'user' => new UserResource($this->whenLoaded('user')),
'comments_count' => $this->whenCounted('comments'),
Expand Down
8 changes: 8 additions & 0 deletions app/Models/Idea.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ public function comments(): HasMany
return $this->hasMany(Comment::class);
}

/**
* @return HasMany<Reaction, $this>
*/
public function reactions(): HasMany
{
return $this->hasMany(Reaction::class);
}

/**
* @return HasMany<IdeaStatusUpdate, $this>
*/
Expand Down
40 changes: 40 additions & 0 deletions app/Models/Reaction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Reaction extends Model
{
/** @use HasFactory<\Database\Factories\ReactionFactory> */
use HasFactory;

/**
* The emoji people can react with.
*
* @var list<string>
*/
public const EMOJIS = ['👍', '❤️', '🎉', '🚀', '👀'];

protected $fillable = [
'emoji',
];

/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

/**
* @return BelongsTo<Idea, $this>
*/
public function idea(): BelongsTo
{
return $this->belongsTo(Idea::class);
}
}
26 changes: 26 additions & 0 deletions database/factories/ReactionFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Database\Factories;

use App\Models\Idea;
use App\Models\Reaction;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Reaction>
*/
class ReactionFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'idea_id' => Idea::factory(),
'user_id' => User::factory(),
'emoji' => fake()->randomElement(Reaction::EMOJIS),
];
}
}
32 changes: 32 additions & 0 deletions database/migrations/2026_07_02_000000_create_reactions_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('reactions', function (Blueprint $table) {
$table->id();
$table->foreignId('idea_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('emoji');
$table->timestamps();

$table->unique(['idea_id', 'user_id', 'emoji']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('reactions');
}
};
126 changes: 126 additions & 0 deletions resources/js/Components/ReactionBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { router, usePage } from '@inertiajs/vue3';
import ReactionController from '@/actions/App/Http/Controllers/ReactionController';

const props = defineProps({
ideaId: {
type: Number,
required: true,
},
reactions: {
type: Array,
default: () => [],
},
});

const isGuest = computed(() => !usePage().props.auth?.user);

const open = ref(false);
const root = ref(null);

const activeReactions = computed(() => props.reactions.filter((reaction) => reaction.count > 0));

const react = (emoji) => {
if (isGuest.value) return;
open.value = false;
router.post(ReactionController.url(props.ideaId), { emoji }, { preserveScroll: true });
};

const onClickOutside = (event) => {
if (root.value && !root.value.contains(event.target)) {
open.value = false;
}
};

const onKeydown = (event) => {
if (event.key === 'Escape') {
open.value = false;
}
};

onMounted(() => {
document.addEventListener('click', onClickOutside);
document.addEventListener('keydown', onKeydown);
});

onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside);
document.removeEventListener('keydown', onKeydown);
});
</script>

<template>
<div ref="root" class="flex flex-wrap items-center gap-2">
<div class="relative">
<button
type="button"
:disabled="isGuest"
:title="isGuest ? 'Sign in to react' : 'Add a reaction'"
:aria-expanded="open"
:class="[
'flex h-9 w-9 items-center justify-center rounded-none border transition-all duration-150',
open
? 'border-neutral-900 text-neutral-900'
: isGuest
? 'border-black/[0.06] text-neutral-300 cursor-not-allowed'
: 'border-black/[0.06] text-neutral-400 hover:border-neutral-900 hover:text-neutral-900 cursor-pointer',
]"
@click="open = !open"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.182 15.182a4.5 4.5 0 01-6.364 0M21 12a9 9 0 11-18 0 9 9 0 0118 0zM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75zm-.375 0h.008v.015h-.008V9.75zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75zm-.375 0h.008v.015h-.008V9.75z" />
</svg>
</button>

<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 -translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="open"
class="absolute left-0 top-full z-10 mt-2 flex divide-x divide-black/[0.06] rounded-none border border-black/[0.06] bg-white shadow-lg shadow-black/[0.04]"
>
<button
v-for="reaction in reactions"
:key="reaction.emoji"
type="button"
:title="`React with ${reaction.emoji}`"
:class="[
'flex h-10 w-10 items-center justify-center text-lg leading-none transition-all duration-150 cursor-pointer',
reaction.reacted ? 'bg-neutral-100' : 'hover:bg-neutral-50',
]"
@click="react(reaction.emoji)"
>
{{ reaction.emoji }}
</button>
</div>
</Transition>
</div>

<button
v-for="reaction in activeReactions"
:key="reaction.emoji"
type="button"
:disabled="isGuest"
:title="isGuest ? 'Sign in to react' : `React with ${reaction.emoji}`"
:class="[
'flex h-9 items-center gap-1.5 rounded-none border px-2.5 transition-all duration-150',
reaction.reacted
? 'border-neutral-900 bg-neutral-50'
: 'border-black/[0.06]',
isGuest ? 'cursor-default' : 'cursor-pointer hover:border-neutral-900',
]"
@click="react(reaction.emoji)"
>
<span class="text-sm leading-none">{{ reaction.emoji }}</span>
<span :class="['text-xs font-semibold tabular-nums', reaction.reacted ? 'text-neutral-900' : 'text-neutral-500']">
{{ reaction.count }}
</span>
</button>
</div>
</template>
6 changes: 6 additions & 0 deletions resources/js/Pages/Ideas/Show.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { computed } from 'vue';
import { usePage, useForm } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import VoteButton from '@/Components/VoteButton.vue';
import ReactionBar from '@/Components/ReactionBar.vue';
import StatusBadge from '@/Components/StatusBadge.vue';
import CommentCard from '@/Components/CommentCard.vue';
import CommentForm from '@/Components/CommentForm.vue';
Expand Down Expand Up @@ -84,6 +85,11 @@ const formatDate = (dateString) => {
</div>

<p class="text-neutral-700 whitespace-pre-line">{{ idea.description }}</p>

<div v-if="idea.reactions" class="mt-6 flex flex-wrap items-center gap-3 border-t border-black/[0.06] pt-6">
<ReactionBar :idea-id="idea.id" :reactions="idea.reactions" />
<span v-if="!user" class="text-xs uppercase tracking-wider text-neutral-400">Sign in to react</span>
</div>
</div>

<div class="mt-8">
Expand Down
Loading
Loading