Skip to content

Commit 8a9d902

Browse files
authored
feat(profile): implement complete profile management system (#83)
* feat(profile): implement complete profile management system - Create profile layout with navigation menu and breadcrumbs - Build comprehensive profile edit form with all user/profile fields - Implement backend API with Supabase integration and REST endpoints - Add profile routes with authentication guards - Extend UserService with profile CRUD operations - Add countries dropdown with 83 country options - Standardize T-shirt sizes in constants package - Add US states dropdown with conditional logic - Add contextual guidance banners and tooltips LFXV2-454, LFXV2-465, LFXV2-466, LFXV2-467, LFXV2-468, LFXV2-469, LFXV2-470, LFXV2-471, LFXV2-472, LFXV2-473 --signoff Signed-off-by: Asitha de Silva <asithade@gmail.com> * refactor(profile): move member info to layout and align data-testid - Move member since and last active to profile layout opposite menu items - Add computed signals for memberSince and lastActive in ProfileLayoutComponent - Remove member info cards from ProfileStatsComponent sidebar - Reorganize profile stats component order: quick actions first, then statistics - Align all profile module data-testid with [section]-[component]-[element] convention - Update profile-email, profile-password, and profile-edit components data-testids Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix(server): m2m auth0 issuer base url Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix(ui): reuse stats Signed-off-by: Asitha de Silva <asithade@gmail.com> --------- Signed-off-by: Asitha de Silva <asithade@gmail.com>
1 parent 8b4450f commit 8a9d902

33 files changed

+1864
-15
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"styleclass",
2727
"supabase",
2828
"timegrid",
29+
"TSHIRT",
2930
"Turborepo",
3031
"Uids",
3132
"viewports"

apps/lfx-pcc/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ PCC_AUTH0_SECRET=sufficiently-long-string
1414
# M2M Token Generation
1515
M2M_AUTH_CLIENT_ID=your-auth0-client-id
1616
M2M_AUTH_CLIENT_SECRET=your-auth0-client-secret
17-
M2M_AUTH_ISSUER_BASE_URL=https://auth.k8s.orb.local
17+
M2M_AUTH_ISSUER_BASE_URL=https://auth.k8s.orb.local/
1818
M2M_AUTH_AUDIENCE=http://lfx-api.k8s.orb.local/
1919

2020
# Microservice Configuration

apps/lfx-pcc/src/app/app.routes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,10 @@ export const routes: Routes = [
2222
canActivate: [authGuard],
2323
data: { preload: true, preloadDelay: 1000 }, // Preload after 1 second for likely navigation
2424
},
25+
{
26+
path: 'profile',
27+
loadComponent: () => import('./layouts/profile-layout/profile-layout.component').then((m) => m.ProfileLayoutComponent),
28+
loadChildren: () => import('./modules/profile/profile.routes').then((m) => m.PROFILE_ROUTES),
29+
canActivate: [authGuard],
30+
},
2531
];
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
@if (statistics(); as stats) {
5+
<div class="flex flex-col gap-4">
6+
<!-- Quick Actions -->
7+
<lfx-card>
8+
<div class="flex flex-col gap-2">
9+
<h4 class="text-sm font-semibold text-gray-900 mb-2">Quick Actions</h4>
10+
<button class="flex items-center gap-2 w-full p-2 text-sm text-left text-gray-700 hover:bg-gray-50 rounded transition-colors">
11+
<i class="fa-light fa-eye text-blue-500"></i>
12+
View Public Profile
13+
</button>
14+
<button class="flex items-center gap-2 w-full p-2 text-sm text-left text-gray-700 hover:bg-gray-50 rounded transition-colors">
15+
<i class="fa-light fa-calendar text-green-500"></i>
16+
View Meeting History
17+
</button>
18+
<button class="flex items-center gap-2 w-full p-2 text-sm text-left text-gray-700 hover:bg-gray-50 rounded transition-colors">
19+
<i class="fa-light fa-chart-bar text-purple-500"></i>
20+
Activity Report
21+
</button>
22+
</div>
23+
</lfx-card>
24+
25+
<!-- Profile Statistics Header -->
26+
<div class="flex items-center gap-2">
27+
<i class="fa-light fa-chart-line text-blue-500"></i>
28+
<h3 class="text-lg font-semibold text-gray-900 mb-0">Profile Statistics</h3>
29+
</div>
30+
31+
<!-- Engagement Statistics -->
32+
<div class="grid grid-cols-2 gap-3">
33+
<!-- Committees -->
34+
<lfx-card styleClass="text-center">
35+
<div class="flex flex-col items-center gap-2">
36+
<div class="bg-purple-100 p-2 rounded-lg">
37+
<i class="fa-light fa-users text-purple-600"></i>
38+
</div>
39+
<div>
40+
<p class="text-lg font-bold text-gray-900">{{ stats!.committees }}</p>
41+
<p class="text-xs text-gray-500">Committees</p>
42+
</div>
43+
</div>
44+
</lfx-card>
45+
46+
<!-- Meetings -->
47+
<lfx-card styleClass="text-center">
48+
<div class="flex flex-col items-center gap-2">
49+
<div class="bg-amber-100 p-2 rounded-lg">
50+
<i class="fa-light fa-video text-amber-600"></i>
51+
</div>
52+
<div>
53+
<p class="text-lg font-bold text-gray-900">{{ stats!.meetings }}</p>
54+
<p class="text-xs text-gray-500">Meetings</p>
55+
</div>
56+
</div>
57+
</lfx-card>
58+
59+
<!-- Contributions -->
60+
<lfx-card styleClass="text-center">
61+
<div class="flex flex-col items-center gap-2">
62+
<div class="bg-indigo-100 p-2 rounded-lg">
63+
<i class="fa-light fa-code-commit text-indigo-600"></i>
64+
</div>
65+
<div>
66+
<p class="text-lg font-bold text-gray-900">{{ stats!.contributions }}</p>
67+
<p class="text-xs text-gray-500">Contributions</p>
68+
</div>
69+
</div>
70+
</lfx-card>
71+
72+
<!-- Active Projects -->
73+
<lfx-card styleClass="text-center">
74+
<div class="flex flex-col items-center gap-2">
75+
<div class="bg-red-100 p-2 rounded-lg">
76+
<i class="fa-light fa-folder-open text-red-600"></i>
77+
</div>
78+
<div>
79+
<p class="text-lg font-bold text-gray-900">{{ stats!.activeProjects }}</p>
80+
<p class="text-xs text-gray-500">Active Projects</p>
81+
</div>
82+
</div>
83+
</lfx-card>
84+
</div>
85+
</div>
86+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
// Additional styles for profile stats component
5+
// Currently using Tailwind classes, custom styles can be added here if needed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { CommonModule } from '@angular/common';
5+
import { Component, computed, input, Signal } from '@angular/core';
6+
import { CombinedProfile, UserStatistics } from '@lfx-pcc/shared/interfaces';
7+
import { CardComponent } from '@shared/components/card/card.component';
8+
9+
@Component({
10+
selector: 'lfx-profile-stats',
11+
standalone: true,
12+
imports: [CommonModule, CardComponent],
13+
templateUrl: './profile-stats.component.html',
14+
styleUrl: './profile-stats.component.scss',
15+
})
16+
export class ProfileStatsComponent {
17+
// Input profile data
18+
public readonly profile = input<CombinedProfile | null>(null);
19+
20+
// Computed statistics
21+
public readonly statistics: Signal<UserStatistics | null> = computed(() => {
22+
const profileData = this.profile();
23+
if (!profileData?.user) return null;
24+
25+
return {
26+
committees: 5, // Mock data
27+
meetings: 23, // Mock data
28+
contributions: 147, // Mock data
29+
activeProjects: 3, // Mock data
30+
memberSince: this.calculateMemberSince(profileData.user.created_at),
31+
lastActive: this.calculateLastActive(profileData.user.updated_at),
32+
};
33+
});
34+
35+
/**
36+
* Calculate how long the user has been a member
37+
*/
38+
private calculateMemberSince(createdAt: string): string {
39+
const created = new Date(createdAt);
40+
const now = new Date();
41+
const diffTime = Math.abs(now.getTime() - created.getTime());
42+
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
43+
44+
if (diffDays < 30) {
45+
return `${diffDays} day${diffDays === 1 ? '' : 's'}`;
46+
} else if (diffDays < 365) {
47+
const months = Math.floor(diffDays / 30);
48+
return `${months} month${months === 1 ? '' : 's'}`;
49+
}
50+
51+
const years = Math.floor(diffDays / 365);
52+
const remainingMonths = Math.floor((diffDays % 365) / 30);
53+
if (remainingMonths > 0) {
54+
return `${years} year${years === 1 ? '' : 's'}, ${remainingMonths} month${remainingMonths === 1 ? '' : 's'}`;
55+
}
56+
return `${years} year${years === 1 ? '' : 's'}`;
57+
}
58+
59+
/**
60+
* Calculate last active time
61+
*/
62+
private calculateLastActive(updatedAt: string): string {
63+
const updated = new Date(updatedAt);
64+
const now = new Date();
65+
const diffTime = Math.abs(now.getTime() - updated.getTime());
66+
const diffMinutes = Math.floor(diffTime / (1000 * 60));
67+
const diffHours = Math.floor(diffTime / (1000 * 60 * 60));
68+
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
69+
70+
if (diffMinutes < 1) {
71+
return 'Just now';
72+
} else if (diffMinutes < 60) {
73+
return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
74+
} else if (diffHours < 24) {
75+
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
76+
} else if (diffDays < 30) {
77+
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
78+
} else if (diffDays < 365) {
79+
const months = Math.floor(diffDays / 30);
80+
return `${months} month${months === 1 ? '' : 's'} ago`;
81+
}
82+
83+
const years = Math.floor(diffDays / 365);
84+
return `${years} year${years === 1 ? '' : 's'} ago`;
85+
}
86+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
<div class="bg-white border-b border-gray-100 py-6 shadow-sm">
5+
<div class="container mx-auto px-8">
6+
<!-- Breadcrumb Navigation -->
7+
<div class="mb-4">
8+
<lfx-breadcrumb [model]="breadcrumbItems()">
9+
<ng-template #item let-item>
10+
<a [routerLink]="item.routerLink" class="flex items-center gap-2 text-sm font-medium text-gray-900 hover:text-gray-600 transition-colors">
11+
@if (item.icon) {
12+
<i [class]="item.icon" class="text-gray-500"></i>
13+
}
14+
{{ item.label }}
15+
</a>
16+
</ng-template>
17+
</lfx-breadcrumb>
18+
</div>
19+
20+
<div class="flex flex-col md:flex-row md:items-start justify-between mb-6">
21+
<div class="flex items-start gap-6">
22+
<!-- Profile Avatar -->
23+
<lfx-avatar
24+
[label]="userInitials()"
25+
size="xlarge"
26+
shape="circle"
27+
[ariaLabel]="profileTitle() + ' avatar'"
28+
styleClass="bg-blue-50 border border-gray-200 w-16 h-16"
29+
data-testid="profile-avatar">
30+
</lfx-avatar>
31+
32+
<div class="flex flex-col gap-1">
33+
<!-- Profile Title -->
34+
<h1 class="text-2xl font-display font-semibold text-gray-900 mb-0" data-testid="profile-title">
35+
{{ profileTitle() }}
36+
</h1>
37+
38+
<!-- Profile Subtitle (Title at Organization) -->
39+
@if (profileSubtitle(); as subtitle) {
40+
<p class="text-black-600 text-sm mb-2" data-testid="profile-subtitle">
41+
{{ subtitle }}
42+
</p>
43+
}
44+
</div>
45+
</div>
46+
47+
<!-- Profile Links -->
48+
<div class="flex items-start gap-6">
49+
<!-- GitHub Profile -->
50+
<a class="text-sm text-gray-600 flex items-center gap-2" data-testid="profile-github-profile">
51+
<i class="fa-brands fa-github text-[#181717] text-2xl cursor-pointer"></i>
52+
</a>
53+
54+
<!-- LinkedIn Profile -->
55+
<a class="text-sm text-gray-600 flex items-center gap-2" data-testid="profile-linkedin-profile">
56+
<i class="fa-brands fa-linkedin text-[#0077b5] text-2xl cursor-pointer"></i>
57+
</a>
58+
</div>
59+
</div>
60+
61+
<!-- Menu Items Section -->
62+
<div class="md:flex items-center justify-start md:justify-between">
63+
<div class="flex items-center gap-3 flex-wrap">
64+
@for (menu of menuItems(); track menu.label) {
65+
<a
66+
[routerLink]="menu.routerLink"
67+
class="pill"
68+
routerLinkActive="bg-blue-50 text-blue-600 border-0"
69+
data-testid="profile-menu-item"
70+
[attr.data-menu-item]="menu.label"
71+
[routerLinkActiveOptions]="menu.routerLinkActiveOptions">
72+
@if (menu.icon) {
73+
<i [class]="menu.icon"></i>
74+
}
75+
{{ menu.label }}
76+
</a>
77+
}
78+
</div>
79+
80+
<!-- Member Since and Last Active -->
81+
@if (profile()?.user && memberSince() && lastActive()) {
82+
<div class="flex items-center gap-6 mt-4 md:mt-0" data-testid="profile-layout-membership-info">
83+
<!-- Member Since -->
84+
<div class="flex items-center gap-2 text-sm text-gray-600" data-testid="profile-layout-member-since">
85+
<i class="fa-light fa-calendar-check text-blue-500"></i>
86+
<span class="font-medium">Member Since:</span>
87+
<span>{{ memberSince() }}</span>
88+
</div>
89+
90+
<!-- Last Active -->
91+
<div class="flex items-center gap-2 text-sm text-gray-600" data-testid="profile-layout-last-active">
92+
<i class="fa-light fa-clock text-green-500"></i>
93+
<span class="font-medium">Last Active:</span>
94+
<span>{{ lastActive() }}</span>
95+
</div>
96+
</div>
97+
}
98+
</div>
99+
</div>
100+
</div>
101+
102+
<!-- Content Area -->
103+
@if (!loading()) {
104+
<div class="container mx-auto px-8 py-8">
105+
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
106+
<!-- Main Content (Left Side) -->
107+
<div class="lg:col-span-3">
108+
<router-outlet></router-outlet>
109+
</div>
110+
111+
<!-- Statistics Sidebar (Right Side) -->
112+
<div class="lg:col-span-1">
113+
<lfx-profile-stats [profile]="profile()"></lfx-profile-stats>
114+
</div>
115+
</div>
116+
</div>
117+
} @else {
118+
<div class="container mx-auto px-8 py-8">
119+
<div class="flex flex-col gap-2 items-center justify-center py-12" data-testid="profile-loading">
120+
<i class="fa-light fa-circle-notch fa-spin text-4xl text-blue-400" data-testid="loading-spinner"></i>
121+
<span class="ml-3 text-gray-600">Loading profile...</span>
122+
</div>
123+
</div>
124+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
// Profile layout specific styles
5+
// Most styling comes from Tailwind classes in the template
6+
// This file is reserved for any custom styling that can't be achieved with Tailwind
7+
8+
:host {
9+
display: block;
10+
11+
.pill {
12+
@apply inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-full transition-colors text-gray-600 border border-gray-200 hover:bg-gray-50;
13+
}
14+
15+
.profile-avatar-placeholder {
16+
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
17+
}
18+
}

0 commit comments

Comments
 (0)