From ad9f21b8aa2092c2067f3c8ab68f9c24a8a70e12 Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 26 Aug 2026 13:10:51 +0600 Subject: [PATCH 1/2] feat(profile): add public student profile page with tabbed architecture --- .../Controllers/UserProfileController.php | 130 +++ resources/js/components/NavBar.vue | 24 +- resources/js/pages/User/Show.vue | 765 ++++++++++++++++++ routes/web.php | 2 + tests/Feature/UserProfileTest.php | 97 +++ 5 files changed, 1011 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/UserProfileController.php create mode 100644 resources/js/pages/User/Show.vue create mode 100644 tests/Feature/UserProfileTest.php diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php new file mode 100644 index 0000000..5f6968f --- /dev/null +++ b/app/Http/Controllers/UserProfileController.php @@ -0,0 +1,130 @@ +firstOrFail(); + + $user->load('roles:id,name'); + + // Study Completions + $completedResourcesCount = $user->completedResources()->count(); + $recentCompletions = $user->completedResources() + ->with(['node:id,name,subject_id', 'node.subject:id,name']) + ->latest('resource_completions.created_at') + ->take(6) + ->get(); + + // Contributor Stats & Blogs + $publishedBlogs = $user->blogs() + ->where('is_published', true) + ->withCount(['reactions', 'comments']) + ->latest() + ->take(3) + ->get(); + $blogsCount = $user->blogs()->where('is_published', true)->count(); + $totalBlogViews = (int) $user->blogs()->where('is_published', true)->sum('views'); + $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) + $recentUploads = Resource::where('user_id', $user->id) + ->with(['node:id,name,subject_id', 'node.subject:id,name']) + ->latest() + ->take(4) + ->get() + ->map(fn ($item) => [ + 'type' => 'upload', + 'title' => $item->title, + 'subtitle' => $item->node?->subject?->name.' · '.$item->node?->name, + 'resource_type' => $item->resource_type, + 'url' => "/resources/{$item->id}", + 'created_at' => $item->created_at?->diffForHumans(), + ]); + + $recentCompleted = ResourceCompletion::where('user_id', $user->id) + ->with(['resource:id,title,node_id', 'resource.node:id,name,subject_id', 'resource.node.subject:id,name']) + ->latest() + ->take(4) + ->get() + ->map(fn ($item) => [ + 'type' => 'completion', + 'title' => $item->resource?->title, + 'subtitle' => $item->resource?->node?->subject?->name.' · '.$item->resource?->node?->name, + 'url' => $item->resource ? "/resources/{$item->resource->id}" : null, + 'created_at' => $item->created_at?->diffForHumans(), + ]) + ->filter(fn ($item) => $item['title'] !== null); + + $recentReactions = BlogReaction::where('user_id', $user->id) + ->with('blog:id,title,slug') + ->latest() + ->take(4) + ->get() + ->map(fn ($item) => [ + 'type' => 'reaction', + 'title' => $item->blog?->title, + 'url' => $item->blog ? "/blogs/{$item->blog->slug}" : null, + 'created_at' => $item->created_at?->diffForHumans(), + ]) + ->filter(fn ($item) => $item['title'] !== null); + + $recentComments = BlogComment::where('user_id', $user->id) + ->with('blog:id,title,slug') + ->latest() + ->take(4) + ->get() + ->map(fn ($item) => [ + 'type' => 'comment', + 'title' => $item->blog?->title, + 'content' => $item->content, + 'url' => $item->blog ? "/blogs/{$item->blog->slug}" : null, + 'created_at' => $item->created_at?->diffForHumans(), + ]) + ->filter(fn ($item) => $item['title'] !== null); + + return Inertia::render('User/Show', [ + 'profileUser' => [ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + 'title' => $user->title, + 'about' => $user->about, + 'institution' => $user->institution, + 'image_url' => $user->image_url, + 'facebook' => $user->facebook, + 'instagram' => $user->instagram, + 'github' => $user->github, + 'created_at' => $user->created_at?->format('M Y') ?? '2026', + 'roles' => $user->roles->pluck('name'), + 'is_staff' => $user->roles->isNotEmpty(), + ], + 'stats' => [ + 'completedResourcesCount' => $completedResourcesCount, + 'blogsCount' => $blogsCount, + 'totalBlogLikes' => (int) $totalBlogLikes, + 'totalBlogViews' => (int) $totalBlogViews, + 'sharedResourcesCount' => $sharedResourcesCount, + ], + 'recentCompletions' => $recentCompletions, + 'blogs' => $publishedBlogs, + 'recentActivities' => [ + 'uploads' => $recentUploads->values(), + 'completions' => $recentCompleted->values(), + 'reactions' => $recentReactions->values(), + 'comments' => $recentComments->values(), + ], + ]); + } +} diff --git a/resources/js/components/NavBar.vue b/resources/js/components/NavBar.vue index d741a54..b96009a 100644 --- a/resources/js/components/NavBar.vue +++ b/resources/js/components/NavBar.vue @@ -225,12 +225,16 @@ onBeforeUnmount(() => {
- Profile Settings + Profile { class="border-t border-slate-200/80 bg-white/95 px-4 pt-3 pb-6 shadow-2xl backdrop-blur-xl md:hidden dark:border-gray-800 dark:bg-gray-950/95" > -
{ {{ user.email }}

-
+
@@ -390,12 +396,16 @@ onBeforeUnmount(() => { >
- Profile Settings + Profile +import { Head, Link, usePage } from '@inertiajs/vue3'; +import { + Calendar, + GraduationCap, + Edit3, + Facebook, + Instagram, + Github, + BadgeCheck, + ArrowRight, + Heart, + MessageSquare, + Eye, + CheckCircle2, + BookOpen, + FileText, + UploadCloud, + Activity, + ArrowUpRight, +} from 'lucide-vue-next'; +import { computed, ref } from 'vue'; + +const props = defineProps<{ + profileUser: { + id: number; + name: string; + username: string; + title: string | null; + about: string | null; + institution: string | null; + image_url: string | null; + facebook: string | null; + instagram: string | null; + github: string | null; + created_at: string; + roles: string[]; + is_staff: boolean; + }; + stats: { + completedResourcesCount: number; + blogsCount: number; + totalBlogLikes: number; + totalBlogViews: number; + sharedResourcesCount: number; + }; + recentCompletions: Array<{ + id: number; + title: string; + resource_type: string; + node?: { + id: number; + name: string; + subject?: { + id: number; + name: string; + }; + }; + }>; + blogs: Array<{ + id: number; + title: string; + slug: string; + excerpt: string | null; + featured_image: string | null; + views: number; + reactions_count: number; + comments_count: number; + created_at: string; + }>; + recentActivities: { + uploads: Array<{ + type: string; + title: string; + subtitle?: string; + resource_type?: string; + url: string | null; + created_at: string; + }>; + completions: Array<{ + type: string; + title: string; + subtitle?: string; + url: string | null; + created_at: string; + }>; + reactions: Array<{ + type: string; + title: string; + url: string | null; + created_at: string; + }>; + comments: Array<{ + type: string; + title: string; + content?: string; + url: string | null; + created_at: string; + }>; + }; +}>(); + +const page = usePage(); +const currentUser = computed(() => page.props.auth?.user); +const isOwnProfile = computed( + () => currentUser.value?.id === props.profileUser.id, +); + +const activeTab = ref<'completed' | 'blogs' | 'activity'>( + props.stats.completedResourcesCount > 0 + ? 'completed' + : props.blogs.length > 0 + ? 'blogs' + : 'completed', +); + +const getRoleBadge = (roles: string[]) => { + if (roles.includes('admin')) { + return { + label: 'Admin', + class: 'bg-rose-50 text-rose-700 border-rose-200/80 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-800/60', + }; + } + + if (roles.includes('editor')) { + return { + label: 'Editor', + class: 'bg-purple-50 text-purple-700 border-purple-200/80 dark:bg-purple-950/40 dark:text-purple-300 dark:border-purple-800/60', + }; + } + + if (roles.includes('manager')) { + return { + label: 'Staff', + class: 'bg-amber-50 text-amber-700 border-amber-200/80 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-800/60', + }; + } + + return { + label: 'Student', + class: 'bg-blue-50 text-blue-700 border-blue-200/80 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-800/60', + }; +}; + +const roleInfo = computed(() => getRoleBadge(props.profileUser.roles)); + +const totalActivitiesCount = computed( + () => + (props.recentActivities.uploads?.length || 0) + + props.recentActivities.reactions.length + + props.recentActivities.comments.length, +); + + + diff --git a/routes/web.php b/routes/web.php index a5d4787..4bfa768 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use App\Http\Controllers\ResourceController; use App\Http\Controllers\ShortUrlController; use App\Http\Controllers\SubjectController; +use App\Http\Controllers\UserProfileController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -58,6 +59,7 @@ Route::get('/blogs', [BlogController::class, 'index']); Route::get('/blogs/{blog}', [BlogController::class, 'show']); + Route::get('/u/{username}', [UserProfileController::class, 'show'])->name('user.profile'); Route::get('/', [SubjectController::class, 'index']) ->defaults('course', 'hsc') diff --git a/tests/Feature/UserProfileTest.php b/tests/Feature/UserProfileTest.php new file mode 100644 index 0000000..b2e5c91 --- /dev/null +++ b/tests/Feature/UserProfileTest.php @@ -0,0 +1,97 @@ +create([ + 'name' => 'Tajim Ahmed', + 'username' => 'tajim_pro', + 'institution' => 'Notre Dame College', + 'about' => 'HSC 26 Candidate', + ]); + + $response = $this->get('/u/tajim_pro'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('User/Show') + ->where('profileUser.username', 'tajim_pro') + ->where('profileUser.name', 'Tajim Ahmed') + ->where('profileUser.institution', 'Notre Dame College') + ); +}); + +test('viewing non-existent username renders 404 page', function () { + $response = $this->get('/u/non_existent_student_999'); + + $response->assertInertia(fn ($page) => $page->component('errors/404')); +}); + +test('public profile renders authored blogs when available', function () { + Role::create(['name' => 'editor']); + + $user = User::factory()->create(['username' => 'contributor_john']); + $user->assignRole('editor'); + + Blog::create([ + 'user_id' => $user->id, + 'title' => 'Calculus Masterclass', + 'slug' => 'calculus-masterclass', + 'content' => 'Sample guide', + 'is_published' => true, + ]); + + $response = $this->get('/u/contributor_john'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('User/Show') + ->where('profileUser.is_staff', true) + ->where('stats.blogsCount', 1) + ->has('blogs', 1) + ->where('blogs.0.title', 'Calculus Masterclass') + ); +}); + +test('public profile accurately displays completed study topics and activities', function () { + $user = User::factory()->create(['username' => 'studious_student']); + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Vector', + 'slug' => 'vector', + ]); + $resource = App\Models\Resource::create([ + 'node_id' => $node->id, + 'title' => 'Vector Lecture Notes', + 'resource_type' => 'image', + 'file_path' => 'resources/vector.jpg', + ]); + + ResourceCompletion::create([ + 'user_id' => $user->id, + 'resource_id' => $resource->id, + ]); + + $response = $this->get('/u/studious_student'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('User/Show') + ->where('stats.completedResourcesCount', 1) + ->has('recentCompletions', 1) + ->where('recentCompletions.0.title', 'Vector Lecture Notes') + ->has('recentActivities.completions', 1) + ); +}); From 23758e63d7652ea17d3fa89612a2113f3929c10e Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 26 Aug 2026 13:10:54 +0600 Subject: [PATCH 2/2] feat(profile): integrate public profile linking across blogs, resources, comments, and team cards --- app/Http/Controllers/BlogController.php | 10 +- app/Http/Controllers/ResourceController.php | 2 +- app/Http/Controllers/SubjectController.php | 2 +- resources/js/components/BlogCard.vue | 9 +- resources/js/components/UserCard.vue | 215 ++++++++++---------- resources/js/components/admin/UserRow.vue | 8 +- resources/js/pages/Blog/Show.vue | 49 +++-- resources/js/pages/Resource.vue | 21 +- resources/js/pages/platform/AboutUs.vue | 9 +- 9 files changed, 180 insertions(+), 145 deletions(-) diff --git a/app/Http/Controllers/BlogController.php b/app/Http/Controllers/BlogController.php index e97160e..78dc382 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') + ->with('user:id,name,username') ->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'); + $blog->load('user:id,name,username,image_path'); $blog->increment('views'); $reactionsCount = $blog->reactions()->count(); $reactors = $blog->reactions() - ->with('user:id,name,image_path,institution') + ->with('user:id,name,username,image_path,institution') ->latest('id') ->limit(50) ->get() @@ -71,7 +71,7 @@ public function show(Blog $blog) ->values(); $comments = $blog->comments() - ->with('user:id,name,image_path,institution') + ->with('user:id,name,username,image_path,institution') ->latest() ->get(); @@ -131,7 +131,7 @@ public function storeComment(Request $request, Blog $blog) 'content' => trim($validated['content']), ]); - $comment->load('user:id,name'); + $comment->load('user:id,name,username,image_path,institution'); $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/app/Http/Controllers/ResourceController.php b/app/Http/Controllers/ResourceController.php index 80f4b2e..91c1f5c 100644 --- a/app/Http/Controllers/ResourceController.php +++ b/app/Http/Controllers/ResourceController.php @@ -38,7 +38,7 @@ public function show($id) $completionsCount = ResourceCompletion::where('resource_id', $id)->count(); $completers = ResourceCompletion::where('resource_id', $id) - ->with('user:id,name,image_path,institution') + ->with('user:id,name,username,image_path,institution') ->latest() ->take(10) ->get() diff --git a/app/Http/Controllers/SubjectController.php b/app/Http/Controllers/SubjectController.php index 7f2456b..d398d6f 100644 --- a/app/Http/Controllers/SubjectController.php +++ b/app/Http/Controllers/SubjectController.php @@ -27,7 +27,7 @@ public function index($course) $featuredBlogs = Blog::where('is_featured', true) ->where('is_published', true) - ->with('user:id,name') + ->with('user:id,name,username') ->withCount(['reactions', 'comments']) ->inRandomOrder() ->limit(3) diff --git a/resources/js/components/BlogCard.vue b/resources/js/components/BlogCard.vue index 5caf7b6..50e58c0 100644 --- a/resources/js/components/BlogCard.vue +++ b/resources/js/components/BlogCard.vue @@ -57,11 +57,18 @@ const formattedDate = computed(() => { > Author {{ blog.user?.name }} + + {{ blog.user?.name }} + -import { Github, Facebook, Instagram } from 'lucide-vue-next'; -import { ref, onMounted, onBeforeUnmount } from 'vue'; +import { Link } from '@inertiajs/vue3'; +import { GraduationCap, ArrowRight } from 'lucide-vue-next'; +import { computed } from 'vue'; -defineProps({ - member: Object, - id: String, +const props = defineProps<{ + member: { + id: number; + name: string; + username?: string; + title?: string | null; + about?: string | null; + institution?: string | null; + image_url?: string | null; + roles?: Array<{ name: string }>; + }; +}>(); + +const profileUrl = computed(() => { + return props.member.username ? `/u/${props.member.username}` : '#'; }); -const activeHash = ref(''); +const roleInfo = computed(() => { + const roleName = props.member.roles?.[0]?.name?.toLowerCase() || 'staff'; -const updateHash = () => { - activeHash.value = window.location.hash; -}; + if (roleName === 'admin') { + return { + label: 'Admin', + class: 'border-rose-200/80 bg-rose-50 text-rose-700 dark:border-rose-800/60 dark:bg-rose-950/40 dark:text-rose-300', + }; + } -onMounted(() => { - updateHash(); - window.addEventListener('hashchange', updateHash); -}); + if (roleName === 'editor') { + return { + label: 'Editor', + class: 'border-purple-200/80 bg-purple-50 text-purple-700 dark:border-purple-800/60 dark:bg-purple-950/40 dark:text-purple-300', + }; + } + + if (roleName === 'manager') { + return { + label: 'Staff', + class: 'border-amber-200/80 bg-amber-50 text-amber-700 dark:border-amber-800/60 dark:bg-amber-950/40 dark:text-amber-300', + }; + } -onBeforeUnmount(() => { - window.removeEventListener('hashchange', updateHash); + return { + label: roleName, + class: 'border-blue-200/80 bg-blue-50 text-blue-700 dark:border-blue-800/60 dark:bg-blue-950/40 dark:text-blue-300', + }; }); diff --git a/resources/js/components/admin/UserRow.vue b/resources/js/components/admin/UserRow.vue index 4c00b17..be72bcb 100644 --- a/resources/js/components/admin/UserRow.vue +++ b/resources/js/components/admin/UserRow.vue @@ -57,10 +57,12 @@ const deleteUser = (id: number) => {
- {{ user.name }} + {{ user.name }} + { By {{ blog.user?.name }} @@ -592,8 +596,13 @@ const goBack = () => { >
-
{{ comment.user?.name?.charAt(0) || 'U' }} -
+
- {{ comment.user?.name || 'Anonymous' }} - + {

{{ blog.user?.name }} @@ -745,13 +763,18 @@ const goBack = () => { No reactions yet.
-

{{ reactor.name }}

@@ -781,7 +804,7 @@ const goBack = () => { {{ reactor.institution }}

-
+
{ class="flex items-center gap-1.5 text-xs text-slate-500 dark:text-gray-400" > { No completions recorded yet.
-

{{ completer.name }}

@@ -839,7 +848,7 @@ watch(isFullscreen, (val) => { {{ completer.institution }}

-
+
-
+

Want your card here?

@@ -73,7 +72,7 @@ const goBack = () => {
Apply for a Role