diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index 5cc2931..dcb7b21 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Mail\UserAppreciationMail; use App\Models\BlogComment; use App\Models\BlogReaction; use App\Models\Node; @@ -9,6 +10,8 @@ use App\Models\Resource; use App\Models\ResourceCompletion; use App\Models\User; +use App\Models\UserAppreciation; +use Illuminate\Support\Facades\Mail; use Inertia\Inertia; class UserProfileController extends Controller @@ -40,7 +43,28 @@ public function show(string $username) $totalBlogLikes = BlogReaction::whereIn('blog_id', $user->blogs()->where('is_published', true)->select('id'))->count(); $sharedResourcesCount = Resource::where('user_id', $user->id)->count(); - // Recent Community Activities (Uploads, completed topics, comments made, reactions given, upvoted folders) + // Appreciations (Received & Given) + $appreciationsCount = $user->appreciationsReceived()->count(); + $appreciatingCount = $user->appreciationsGiven()->count(); + $isAppreciated = auth()->check() + ? $user->appreciationsReceived()->where('appreciator_id', auth()->id())->exists() + : false; + + $appreciators = $user->appreciators() + ->select(['users.id', 'users.name', 'users.username', 'users.image_path', 'users.institution']) + ->with('roles:id,name') + ->latest('user_appreciations.id') + ->take(50) + ->get(); + + $appreciating = $user->appreciatingUsers() + ->select(['users.id', 'users.name', 'users.username', 'users.image_path', 'users.institution']) + ->with('roles:id,name') + ->latest('user_appreciations.id') + ->take(50) + ->get(); + + // Recent Community Activities (Uploads, completed topics, comments made, reactions given, upvoted folders, appreciations given) $recentUploads = Resource::where('user_id', $user->id) ->with(['node:id,name,subject_id', 'node.subject:id,name']) ->latest() @@ -125,6 +149,21 @@ public function show(string $username) ->filter() ->values(); + $recentAppreciations = UserAppreciation::where('appreciator_id', $user->id) + ->with('user:id,name,username') + ->latest('id') + ->take(4) + ->get() + ->map(fn ($item) => [ + 'type' => 'appreciation', + 'title' => $item->user?->name, + 'username' => $item->user?->username, + 'url' => $item->user ? "/u/{$item->user->username}" : null, + 'created_at' => $item->created_at?->diffForHumans(), + ]) + ->filter(fn ($item) => $item['title'] !== null) + ->values(); + // Suggested / Discover community members: 2 contributors + 2 general users $contributorUsers = User::where('id', '!=', $user->id) ->whereNotNull('username') @@ -171,6 +210,11 @@ public function show(string $username) 'totalBlogViews' => (int) $totalBlogViews, 'sharedResourcesCount' => $sharedResourcesCount, ], + 'appreciationsCount' => $appreciationsCount, + 'appreciatingCount' => $appreciatingCount, + 'isAppreciated' => $isAppreciated, + 'appreciators' => $appreciators, + 'appreciating' => $appreciating, 'recentCompletions' => $recentCompletions, 'blogs' => $publishedBlogs, 'recentActivities' => [ @@ -179,11 +223,45 @@ public function show(string $username) 'reactions' => $recentReactions->values(), 'comments' => $recentComments->values(), 'upvotes' => $recentUpvotes->values(), + 'appreciations' => $recentAppreciations->values(), ], 'suggestedUsers' => $suggestedUsers, ]); } + public function toggleAppreciate(User $user) + { + $currentAuthUser = auth()->user(); + + // Cannot appreciate own profile + if ($currentAuthUser->id === $user->id) { + return back(); + } + + $existing = UserAppreciation::where('user_id', $user->id) + ->where('appreciator_id', $currentAuthUser->id) + ->first(); + + if ($existing) { + $existing->delete(); + } else { + UserAppreciation::create([ + 'user_id' => $user->id, + 'appreciator_id' => $currentAuthUser->id, + ]); + + $appreciationsCount = $user->appreciationsReceived()->count(); + $milestones = [1, 10, 25, 50, 100, 250, 500, 1000]; + $isMilestone = in_array($appreciationsCount, $milestones, true) || ($appreciationsCount > 1000 && $appreciationsCount % 500 === 0); + + if ($isMilestone && $user->email && $user->receive_emails !== false) { + Mail::to($user->email)->queue(UserAppreciationMail::forMilestone($user, $currentAuthUser, $appreciationsCount)); + } + } + + return back(); + } + private function buildNodeUrl(Node $node): ?string { if (! $node->subject) { diff --git a/app/Mail/UserAppreciationMail.php b/app/Mail/UserAppreciationMail.php new file mode 100644 index 0000000..42af4eb --- /dev/null +++ b/app/Mail/UserAppreciationMail.php @@ -0,0 +1,72 @@ +username}"; + $appreciatorName = htmlspecialchars($appreciator->name, ENT_QUOTES, 'UTF-8'); + $recipientName = htmlspecialchars($user->name, ENT_QUOTES, 'UTF-8'); + + if ($milestoneCount === 1) { + $mailSubject = 'Someone appreciated your profile on HSCStack! ❤️'; + $headline = 'Congratulations! You received your first community appreciation ❤️'; + $message = "
{$appreciatorName} just appreciated your profile and contributions on HSCStack.
" + .'Your presence and contributions are helping fellow students in the community. Keep up the amazing work!
'; + } else { + $mailSubject = "🎉 Milestone: {$milestoneCount} people have appreciated your profile!"; + $headline = "🎉 Exciting News! {$milestoneCount} Appreciations Milestone reached!"; + $message = "Your profile on HSCStack just reached {$milestoneCount} community appreciations, with the latest from {$appreciatorName}!
" + .'Thank you for inspiring and supporting students and contributors across the platform.
'; + } + + $mailContent = "{$headline}
" + .$message + .'' + ."" + .'View Your Profile →' + .'' + .'
'; + + return new self($mailSubject, $mailContent, $user->name); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: $this->mailSubject, + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.bulk_announcement', + with: [ + 'mailSubject' => $this->mailSubject, + 'mailContent' => $this->mailContent, + 'recipientName' => $this->recipientName, + 'imageUrl' => null, + ], + ); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index ad49b93..bda86bd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -113,4 +113,24 @@ public function nodes(): HasMany { return $this->hasMany(Node::class); } + + public function appreciationsReceived(): HasMany + { + return $this->hasMany(UserAppreciation::class, 'user_id'); + } + + public function appreciationsGiven(): HasMany + { + return $this->hasMany(UserAppreciation::class, 'appreciator_id'); + } + + public function appreciators() + { + return $this->belongsToMany(User::class, 'user_appreciations', 'user_id', 'appreciator_id'); + } + + public function appreciatingUsers() + { + return $this->belongsToMany(User::class, 'user_appreciations', 'appreciator_id', 'user_id'); + } } diff --git a/app/Models/UserAppreciation.php b/app/Models/UserAppreciation.php new file mode 100644 index 0000000..4d8d2db --- /dev/null +++ b/app/Models/UserAppreciation.php @@ -0,0 +1,24 @@ +belongsTo(User::class, 'user_id'); + } + + public function appreciator(): BelongsTo + { + return $this->belongsTo(User::class, 'appreciator_id'); + } +} diff --git a/database/migrations/2026_08_26_180000_create_user_appreciations_table.php b/database/migrations/2026_08_26_180000_create_user_appreciations_table.php new file mode 100644 index 0000000..ade8cd2 --- /dev/null +++ b/database/migrations/2026_08_26_180000_create_user_appreciations_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('appreciator_id')->constrained('users')->cascadeOnDelete(); + $table->unique(['user_id', 'appreciator_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_appreciations'); + } +}; diff --git a/resources/js/pages/Node.vue b/resources/js/pages/Node.vue index 0e36da7..1e65708 100644 --- a/resources/js/pages/Node.vue +++ b/resources/js/pages/Node.vue @@ -119,6 +119,7 @@ const handleVote = (type: 'up' | 'down') => { if (previousVote === type) { // Toggle off localUserVote.value = null; + if (type === 'up') { localUpvotesCount.value = Math.max(0, localUpvotesCount.value - 1); localUpvoters.value = localUpvoters.value.filter( @@ -133,8 +134,10 @@ const handleVote = (type: 'up' | 'down') => { } else if (previousVote === null) { // New vote localUserVote.value = type; + if (type === 'up') { localUpvotesCount.value += 1; + if (currentUser.value) { localUpvoters.value = [ { @@ -157,12 +160,14 @@ const handleVote = (type: 'up' | 'down') => { } else { // Switching vote localUserVote.value = type; + if (type === 'up') { localUpvotesCount.value += 1; localDownvotesCount.value = Math.max( 0, localDownvotesCount.value - 1, ); + if (currentUser.value) { localUpvoters.value = [ { diff --git a/resources/js/pages/User/Show.vue b/resources/js/pages/User/Show.vue index 0ecffc0..530fde9 100644 --- a/resources/js/pages/User/Show.vue +++ b/resources/js/pages/User/Show.vue @@ -1,27 +1,29 @@ @@ -323,18 +409,60 @@ const totalActivitiesCount = computed( - +{{ profileUser.about }}
+ ++ {{ localAppreciationsCount }} community + {{ + localAppreciationsCount === 1 + ? 'member' + : 'members' + }} +
++ {{ person.name }} +
++ {{ + person.institution || + '@' + person.username + }} +
++ {{ appreciatingCount }} community + {{ appreciatingCount === 1 ? 'member' : 'members' }} +
++ {{ person.name }} +
++ {{ + person.institution || + '@' + person.username + }} +
++ You need to be logged in to send appreciation and support + fellow students and contributors. +
+ +