From d7582a5f4d42d172fd66b0dfb24eb3a251665ab7 Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 26 Aug 2026 13:33:35 +0600 Subject: [PATCH 1/5] feat(seo): add Open Graph and Twitter card meta tags for user profiles --- resources/views/app.blade.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 9b9ab66..c20e3b0 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -28,6 +28,24 @@ $ogImage = data_get($blog, 'featured_image') ?: url('/feature.png'); $ogType = 'article'; $ogUrl = data_get($blog, 'slug') ? url('/blogs/' . data_get($blog, 'slug')) : url()->current(); + } elseif ($pageComponent === 'User/Show' && !empty($props['profileUser'])) { + $profileUser = $props['profileUser']; + $userName = data_get($profileUser, 'name'); + $userHandle = data_get($profileUser, 'username'); + $userTitle = data_get($profileUser, 'title'); + $userInstitution = data_get($profileUser, 'institution'); + $userAbout = data_get($profileUser, 'about'); + + $metaTitle = "{$userName} (@{$userHandle}) - " . config('app.name', 'HSCStack'); + $ogTitle = "{$userName} (@{$userHandle})"; + + $descParts = array_filter([$userTitle, $userInstitution, $userAbout]); + $metaDescription = !empty($descParts) + ? implode(' · ', array_slice($descParts, 0, 2)) + : "View {$userName}'s profile, completed study topics, and contributions on HSCStack."; + $ogImage = data_get($profileUser, 'image_url') ?: url('/feature.png'); + $ogType = 'profile'; + $ogUrl = $userHandle ? url('/u/' . $userHandle) : url()->current(); } elseif ($pageComponent === 'Node' && (!empty($props['breadcrumb']) || !empty($props['subject']))) { $crumbs = $props['breadcrumb'] ?? []; $lastCrumb = is_array($crumbs) && count($crumbs) ? end($crumbs) : null; From b2e0aa8ddeb6a18092cb9911366a077836eb6b68 Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 26 Aug 2026 13:33:40 +0600 Subject: [PATCH 2/5] feat(profile): add suggested people section with contributors and random members --- .../Controllers/UserProfileController.php | 24 +++ resources/js/pages/User/Show.vue | 141 +++++++++++++++++- tests/Feature/UserProfileTest.php | 27 ++++ 3 files changed, 191 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index 5f6968f..987ca59 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -94,6 +94,29 @@ public function show(string $username) ]) ->filter(fn ($item) => $item['title'] !== null); + // Suggested / Discover community members: 2 contributors + 2 general users + $contributorUsers = User::where('id', '!=', $user->id) + ->whereNotNull('username') + ->whereHas('roles') + ->select(['id', 'name', 'username', 'title', 'institution', 'image_path', 'about']) + ->with('roles:id,name') + ->inRandomOrder() + ->take(2) + ->get(); + + $excludedIds = $contributorUsers->pluck('id')->push($user->id)->all(); + $remainingNeeded = 4 - $contributorUsers->count(); + + $randomUsers = User::whereNotIn('id', $excludedIds) + ->whereNotNull('username') + ->select(['id', 'name', 'username', 'title', 'institution', 'image_path', 'about']) + ->with('roles:id,name') + ->inRandomOrder() + ->take($remainingNeeded) + ->get(); + + $suggestedUsers = $contributorUsers->concat($randomUsers)->shuffle()->values(); + return Inertia::render('User/Show', [ 'profileUser' => [ 'id' => $user->id, @@ -125,6 +148,7 @@ public function show(string $username) 'reactions' => $recentReactions->values(), 'comments' => $recentComments->values(), ], + 'suggestedUsers' => $suggestedUsers, ]); } } diff --git a/resources/js/pages/User/Show.vue b/resources/js/pages/User/Show.vue index 8bccd1e..1c09cc8 100644 --- a/resources/js/pages/User/Show.vue +++ b/resources/js/pages/User/Show.vue @@ -18,6 +18,7 @@ import { UploadCloud, Activity, ArrowUpRight, + Users, } from 'lucide-vue-next'; import { computed, ref } from 'vue'; @@ -98,6 +99,17 @@ const props = defineProps<{ created_at: string; }>; }; + suggestedUsers?: Array<{ + id: number; + name: string; + username: string; + title: string | null; + institution: string | null; + image_url: string | null; + image_path: string | null; + about: string | null; + roles?: Array<{ id: number; name: string }>; + }>; }>(); const page = usePage(); @@ -156,7 +168,53 @@ const totalActivitiesCount = computed( + + + + + + + + @@ -760,6 +818,87 @@ const totalActivitiesCount = computed( + + +
+
+
+ + Discover People +
+ + Meet team → + +
+ +
+ +
+
+ +
+ {{ person.name.charAt(0).toUpperCase() }} +
+
+
+
+

+ {{ person.name }} +

+ + + +
+

+ {{ person.title || person.institution }} +

+

+ @{{ person.username }} +

+
+
+ + + +
+
diff --git a/tests/Feature/UserProfileTest.php b/tests/Feature/UserProfileTest.php index b2e5c91..478fd23 100644 --- a/tests/Feature/UserProfileTest.php +++ b/tests/Feature/UserProfileTest.php @@ -18,11 +18,15 @@ $response = $this->get('/u/tajim_pro'); $response->assertOk(); + $response->assertSee('', false); + $response->assertSee('', false); + $response->assertSee('', false); $response->assertInertia(fn ($page) => $page ->component('User/Show') ->where('profileUser.username', 'tajim_pro') ->where('profileUser.name', 'Tajim Ahmed') ->where('profileUser.institution', 'Notre Dame College') + ->has('suggestedUsers') ); }); @@ -95,3 +99,26 @@ ->has('recentActivities.completions', 1) ); }); + +test('public profile includes both contributors and random users in suggestions', function () { + $role = Role::firstOrCreate(['name' => 'editor']); + + $mainUser = User::factory()->create(['username' => 'current_user']); + + $contributor1 = User::factory()->create(['username' => 'contrib_1']); + $contributor1->assignRole($role); + + $contributor2 = User::factory()->create(['username' => 'contrib_2']); + $contributor2->assignRole($role); + + $student1 = User::factory()->create(['username' => 'student_1']); + $student2 = User::factory()->create(['username' => 'student_2']); + + $response = $this->get('/u/current_user'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('User/Show') + ->has('suggestedUsers', 4) + ); +}); From c9c567a071c45cd7fd47acb808cc7039b311a3fe Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 26 Aug 2026 13:33:42 +0600 Subject: [PATCH 3/5] feat(ui): display verified contributor badges across cards, articles, and comments --- app/Http/Controllers/BlogController.php | 10 ++--- resources/js/components/BlogCard.vue | 20 +++++++--- resources/js/components/UserCard.vue | 25 ++++++++---- resources/js/pages/Blog/Show.vue | 51 ++++++++++++++++++------- 4 files changed, 75 insertions(+), 31 deletions(-) diff --git a/app/Http/Controllers/BlogController.php b/app/Http/Controllers/BlogController.php index 78dc382..1df4fd2 100644 --- a/app/Http/Controllers/BlogController.php +++ b/app/Http/Controllers/BlogController.php @@ -25,7 +25,7 @@ public function index(Request $request) 'views', 'created_at', ]) - ->with('user:id,name,username') + ->with(['user:id,name,username', 'user.roles:id,name']) ->withCount(['reactions', 'comments']) ->where('is_published', true); @@ -56,13 +56,13 @@ public function show(Blog $blog) { abort_unless($blog->is_published, 404); - $blog->load('user:id,name,username,image_path'); + $blog->load(['user:id,name,username,image_path', 'user.roles:id,name']); $blog->increment('views'); $reactionsCount = $blog->reactions()->count(); $reactors = $blog->reactions() - ->with('user:id,name,username,image_path,institution') + ->with(['user:id,name,username,image_path,institution', 'user.roles:id,name']) ->latest('id') ->limit(50) ->get() @@ -71,7 +71,7 @@ public function show(Blog $blog) ->values(); $comments = $blog->comments() - ->with('user:id,name,username,image_path,institution') + ->with(['user:id,name,username,image_path,institution', 'user.roles:id,name']) ->latest() ->get(); @@ -131,7 +131,7 @@ public function storeComment(Request $request, Blog $blog) 'content' => trim($validated['content']), ]); - $comment->load('user:id,name,username,image_path,institution'); + $comment->load(['user:id,name,username,image_path,institution', 'user.roles:id,name']); $blog->loadMissing('user:id,name,email,receive_emails'); if ($blog->user && $blog->user_id !== $userId && $blog->user->email && $blog->user->receive_emails !== false) { diff --git a/resources/js/components/BlogCard.vue b/resources/js/components/BlogCard.vue index 50e58c0..fe5ce6f 100644 --- a/resources/js/components/BlogCard.vue +++ b/resources/js/components/BlogCard.vue @@ -1,6 +1,6 @@ + + diff --git a/resources/js/pages/Blog/Show.vue b/resources/js/pages/Blog/Show.vue index 043dd6f..03c4ee0 100644 --- a/resources/js/pages/Blog/Show.vue +++ b/resources/js/pages/Blog/Show.vue @@ -17,6 +17,7 @@ import { BadgeCheck, } from 'lucide-vue-next'; import { computed, ref, watch } from 'vue'; +import UserListItem from '@/components/UserListItem.vue'; const props = defineProps({ blog: { @@ -779,55 +780,12 @@ const goBack = () => { No reactions yet. - -
- - - {{ reactor.name?.charAt(0) || 'U' }} - -
-
-
-

- {{ reactor.name }} -

- -
-

- {{ reactor.institution }} -

-
- + :user="reactor" + theme="rose" + />
{ No completions recorded yet.
- -
- - - {{ completer.name?.charAt(0) || 'U' }} - -
-
-

- {{ completer.name }} -

-

- {{ completer.institution }} -

-
- + :user="completer" + theme="emerald" + />
0" - class="h-3.5 w-3.5 fill-blue-50 text-blue-600 stroke-[2.2] dark:fill-blue-950/60 dark:text-blue-400" + class="h-3.5 w-3.5 fill-blue-50 stroke-[2.2] text-blue-600 dark:fill-blue-950/60 dark:text-blue-400" title="Verified Contributor" /> @@ -75,7 +75,7 @@ const formattedDate = computed(() => { {{ blog.user?.name }} diff --git a/resources/js/components/UserListItem.vue b/resources/js/components/UserListItem.vue index ccca0df..0954ac8 100644 --- a/resources/js/components/UserListItem.vue +++ b/resources/js/components/UserListItem.vue @@ -36,9 +36,11 @@ const avatarBgClass = computed(() => { if (props.theme === 'rose') { return 'bg-rose-50 text-rose-600 dark:bg-rose-950/60 dark:text-rose-300 group-hover/user:ring-rose-400'; } + if (props.theme === 'emerald') { return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300 group-hover/user:ring-emerald-400'; } + return 'bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-300 group-hover/user:ring-indigo-400'; }); @@ -71,7 +73,7 @@ const avatarBgClass = computed(() => {

diff --git a/resources/js/pages/Blog/Show.vue b/resources/js/pages/Blog/Show.vue index 03c4ee0..bb5077d 100644 --- a/resources/js/pages/Blog/Show.vue +++ b/resources/js/pages/Blog/Show.vue @@ -281,8 +281,10 @@ const goBack = () => { > {{ blog.user?.name }} @@ -629,19 +631,26 @@ const goBack = () => {
-
+
- {{ comment.user?.name || 'Anonymous' }} + {{ + comment.user?.name || 'Anonymous' + }} @@ -715,8 +724,10 @@ const goBack = () => { > {{ blog.user?.name }} diff --git a/resources/js/pages/Resource.vue b/resources/js/pages/Resource.vue index 8e2b178..e7b20d8 100644 --- a/resources/js/pages/Resource.vue +++ b/resources/js/pages/Resource.vue @@ -16,8 +16,8 @@ import { CheckCircle2, } from 'lucide-vue-next'; import { computed, ref, watch } from 'vue'; -import YouTubePlayer from '../components/YouTubePlayer.vue'; import UserListItem from '@/components/UserListItem.vue'; +import YouTubePlayer from '../components/YouTubePlayer.vue'; const props = defineProps({ resource: { diff --git a/resources/js/pages/User/Show.vue b/resources/js/pages/User/Show.vue index 1c09cc8..0aaa472 100644 --- a/resources/js/pages/User/Show.vue +++ b/resources/js/pages/User/Show.vue @@ -825,8 +825,12 @@ const totalActivitiesCount = computed( class="space-y-3 pt-2" >
-
- +
+ Discover People