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
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"styleclass",
"supabase",
"timegrid",
"TSHIRT",
"Turborepo",
"Uids",
"viewports"
Expand Down
2 changes: 1 addition & 1 deletion apps/lfx-pcc/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ PCC_AUTH0_SECRET=sufficiently-long-string
# M2M Token Generation
M2M_AUTH_CLIENT_ID=your-auth0-client-id
M2M_AUTH_CLIENT_SECRET=your-auth0-client-secret
M2M_AUTH_ISSUER_BASE_URL=https://auth.k8s.orb.local
M2M_AUTH_ISSUER_BASE_URL=https://auth.k8s.orb.local/
M2M_AUTH_AUDIENCE=http://lfx-api.k8s.orb.local/

# Microservice Configuration
Expand Down
6 changes: 6 additions & 0 deletions apps/lfx-pcc/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,10 @@ export const routes: Routes = [
canActivate: [authGuard],
data: { preload: true, preloadDelay: 1000 }, // Preload after 1 second for likely navigation
},
{
path: 'profile',
loadComponent: () => import('./layouts/profile-layout/profile-layout.component').then((m) => m.ProfileLayoutComponent),
loadChildren: () => import('./modules/profile/profile.routes').then((m) => m.PROFILE_ROUTES),
canActivate: [authGuard],
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
<!-- SPDX-License-Identifier: MIT -->

@if (statistics(); as stats) {
<div class="flex flex-col gap-4">
<!-- Quick Actions -->
<lfx-card>
<div class="flex flex-col gap-2">
<h4 class="text-sm font-semibold text-gray-900 mb-2">Quick Actions</h4>
<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">
<i class="fa-light fa-eye text-blue-500"></i>
View Public Profile
</button>
<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">
<i class="fa-light fa-calendar text-green-500"></i>
View Meeting History
</button>
<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">
<i class="fa-light fa-chart-bar text-purple-500"></i>
Activity Report
</button>
</div>
</lfx-card>

<!-- Profile Statistics Header -->
<div class="flex items-center gap-2">
<i class="fa-light fa-chart-line text-blue-500"></i>
<h3 class="text-lg font-semibold text-gray-900 mb-0">Profile Statistics</h3>
</div>

<!-- Engagement Statistics -->
<div class="grid grid-cols-2 gap-3">
<!-- Committees -->
<lfx-card styleClass="text-center">
<div class="flex flex-col items-center gap-2">
<div class="bg-purple-100 p-2 rounded-lg">
<i class="fa-light fa-users text-purple-600"></i>
</div>
<div>
<p class="text-lg font-bold text-gray-900">{{ stats!.committees }}</p>
<p class="text-xs text-gray-500">Committees</p>
</div>
</div>
</lfx-card>

<!-- Meetings -->
<lfx-card styleClass="text-center">
<div class="flex flex-col items-center gap-2">
<div class="bg-amber-100 p-2 rounded-lg">
<i class="fa-light fa-video text-amber-600"></i>
</div>
<div>
<p class="text-lg font-bold text-gray-900">{{ stats!.meetings }}</p>
<p class="text-xs text-gray-500">Meetings</p>
</div>
</div>
</lfx-card>

<!-- Contributions -->
<lfx-card styleClass="text-center">
<div class="flex flex-col items-center gap-2">
<div class="bg-indigo-100 p-2 rounded-lg">
<i class="fa-light fa-code-commit text-indigo-600"></i>
</div>
<div>
<p class="text-lg font-bold text-gray-900">{{ stats!.contributions }}</p>
<p class="text-xs text-gray-500">Contributions</p>
</div>
</div>
</lfx-card>

<!-- Active Projects -->
<lfx-card styleClass="text-center">
<div class="flex flex-col items-center gap-2">
<div class="bg-red-100 p-2 rounded-lg">
<i class="fa-light fa-folder-open text-red-600"></i>
</div>
<div>
<p class="text-lg font-bold text-gray-900">{{ stats!.activeProjects }}</p>
<p class="text-xs text-gray-500">Active Projects</p>
</div>
</div>
</lfx-card>
</div>
</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

// Additional styles for profile stats component
// Currently using Tailwind classes, custom styles can be added here if needed
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

import { CommonModule } from '@angular/common';
import { Component, computed, input, Signal } from '@angular/core';
import { CombinedProfile, UserStatistics } from '@lfx-pcc/shared/interfaces';
import { CardComponent } from '@shared/components/card/card.component';

@Component({
selector: 'lfx-profile-stats',
standalone: true,
imports: [CommonModule, CardComponent],
templateUrl: './profile-stats.component.html',
styleUrl: './profile-stats.component.scss',
})
export class ProfileStatsComponent {
// Input profile data
public readonly profile = input<CombinedProfile | null>(null);

// Computed statistics
public readonly statistics: Signal<UserStatistics | null> = computed(() => {
const profileData = this.profile();
if (!profileData?.user) return null;

return {
committees: 5, // Mock data
meetings: 23, // Mock data
contributions: 147, // Mock data
activeProjects: 3, // Mock data
memberSince: this.calculateMemberSince(profileData.user.created_at),
lastActive: this.calculateLastActive(profileData.user.updated_at),
};
});

/**
* Calculate how long the user has been a member
*/
private calculateMemberSince(createdAt: string): string {
const created = new Date(createdAt);
const now = new Date();
const diffTime = Math.abs(now.getTime() - created.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));

if (diffDays < 30) {
return `${diffDays} day${diffDays === 1 ? '' : 's'}`;
} else if (diffDays < 365) {
const months = Math.floor(diffDays / 30);
return `${months} month${months === 1 ? '' : 's'}`;
}

const years = Math.floor(diffDays / 365);
const remainingMonths = Math.floor((diffDays % 365) / 30);
if (remainingMonths > 0) {
return `${years} year${years === 1 ? '' : 's'}, ${remainingMonths} month${remainingMonths === 1 ? '' : 's'}`;
}
return `${years} year${years === 1 ? '' : 's'}`;
}

/**
* Calculate last active time
*/
private calculateLastActive(updatedAt: string): string {
const updated = new Date(updatedAt);
const now = new Date();
const diffTime = Math.abs(now.getTime() - updated.getTime());
const diffMinutes = Math.floor(diffTime / (1000 * 60));
const diffHours = Math.floor(diffTime / (1000 * 60 * 60));
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));

if (diffMinutes < 1) {
return 'Just now';
} else if (diffMinutes < 60) {
return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
} else if (diffDays < 30) {
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
} else if (diffDays < 365) {
const months = Math.floor(diffDays / 30);
return `${months} month${months === 1 ? '' : 's'} ago`;
}

const years = Math.floor(diffDays / 365);
return `${years} year${years === 1 ? '' : 's'} ago`;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
<!-- SPDX-License-Identifier: MIT -->

<div class="bg-white border-b border-gray-100 py-6 shadow-sm">
<div class="container mx-auto px-8">
<!-- Breadcrumb Navigation -->
<div class="mb-4">
<lfx-breadcrumb [model]="breadcrumbItems()">
<ng-template #item let-item>
<a [routerLink]="item.routerLink" class="flex items-center gap-2 text-sm font-medium text-gray-900 hover:text-gray-600 transition-colors">
@if (item.icon) {
<i [class]="item.icon" class="text-gray-500"></i>
}
{{ item.label }}
</a>
</ng-template>
</lfx-breadcrumb>
</div>

<div class="flex flex-col md:flex-row md:items-start justify-between mb-6">
<div class="flex items-start gap-6">
<!-- Profile Avatar -->
<lfx-avatar
[label]="userInitials()"
size="xlarge"
shape="circle"
[ariaLabel]="profileTitle() + ' avatar'"
styleClass="bg-blue-50 border border-gray-200 w-16 h-16"
data-testid="profile-avatar">
</lfx-avatar>

<div class="flex flex-col gap-1">
<!-- Profile Title -->
<h1 class="text-2xl font-display font-semibold text-gray-900 mb-0" data-testid="profile-title">
{{ profileTitle() }}
</h1>

<!-- Profile Subtitle (Title at Organization) -->
@if (profileSubtitle(); as subtitle) {
<p class="text-black-600 text-sm mb-2" data-testid="profile-subtitle">
{{ subtitle }}
</p>
}
</div>
</div>

<!-- Profile Links -->
<div class="flex items-start gap-6">
<!-- GitHub Profile -->
<a class="text-sm text-gray-600 flex items-center gap-2" data-testid="profile-github-profile">
<i class="fa-brands fa-github text-[#181717] text-2xl cursor-pointer"></i>
</a>

<!-- LinkedIn Profile -->
<a class="text-sm text-gray-600 flex items-center gap-2" data-testid="profile-linkedin-profile">
<i class="fa-brands fa-linkedin text-[#0077b5] text-2xl cursor-pointer"></i>
</a>
</div>
</div>

<!-- Menu Items Section -->
<div class="md:flex items-center justify-start md:justify-between">
<div class="flex items-center gap-3 flex-wrap">
@for (menu of menuItems(); track menu.label) {
<a
[routerLink]="menu.routerLink"
class="pill"
routerLinkActive="bg-blue-50 text-blue-600 border-0"
data-testid="profile-menu-item"
[attr.data-menu-item]="menu.label"
[routerLinkActiveOptions]="menu.routerLinkActiveOptions">
@if (menu.icon) {
<i [class]="menu.icon"></i>
}
{{ menu.label }}
</a>
}
</div>

<!-- Member Since and Last Active -->
@if (profile()?.user && memberSince() && lastActive()) {
<div class="flex items-center gap-6 mt-4 md:mt-0" data-testid="profile-layout-membership-info">
<!-- Member Since -->
<div class="flex items-center gap-2 text-sm text-gray-600" data-testid="profile-layout-member-since">
<i class="fa-light fa-calendar-check text-blue-500"></i>
<span class="font-medium">Member Since:</span>
<span>{{ memberSince() }}</span>
</div>

<!-- Last Active -->
<div class="flex items-center gap-2 text-sm text-gray-600" data-testid="profile-layout-last-active">
<i class="fa-light fa-clock text-green-500"></i>
<span class="font-medium">Last Active:</span>
<span>{{ lastActive() }}</span>
</div>
</div>
}
</div>
</div>
</div>

<!-- Content Area -->
@if (!loading()) {
<div class="container mx-auto px-8 py-8">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Main Content (Left Side) -->
<div class="lg:col-span-3">
<router-outlet></router-outlet>
</div>

<!-- Statistics Sidebar (Right Side) -->
<div class="lg:col-span-1">
<lfx-profile-stats [profile]="profile()"></lfx-profile-stats>
</div>
</div>
</div>
} @else {
<div class="container mx-auto px-8 py-8">
<div class="flex flex-col gap-2 items-center justify-center py-12" data-testid="profile-loading">
<i class="fa-light fa-circle-notch fa-spin text-4xl text-blue-400" data-testid="loading-spinner"></i>
<span class="ml-3 text-gray-600">Loading profile...</span>
</div>
</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

// Profile layout specific styles
// Most styling comes from Tailwind classes in the template
// This file is reserved for any custom styling that can't be achieved with Tailwind

:host {
display: block;

.pill {
@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;
}

.profile-avatar-placeholder {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
}
}
Loading