Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
18 changes: 17 additions & 1 deletion apps/lfx-one/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,24 @@ import { authGuard } from './shared/guards/auth.guard';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./modules/pages/home/home.component').then((m) => m.HomeComponent),
canActivate: [authGuard],
loadComponent: () => import('./layouts/main-layout/main-layout.component').then((m) => m.MainLayoutComponent),
children: [
{
path: '',
loadComponent: () => import('./modules/pages/dashboard/dashboard.component').then((m) => m.DashboardComponent),
},
{
path: 'projects',
loadComponent: () => import('./modules/pages/home/home.component').then((m) => m.HomeComponent),
},
],
},
// Old UI route - shows when "Old UI" persona is selected
{
path: 'old-ui',
canActivate: [authGuard],
loadComponent: () => import('./modules/pages/home/home.component').then((m) => m.HomeComponent),
},
{
path: 'meetings',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
<!-- SPDX-License-Identifier: MIT -->

<div class="flex min-h-screen pt-4">
<!-- Sidebar - Desktop -->
<div class="hidden lg:block w-72 flex-shrink-0 fixed top-[6rem] left-0">
<lfx-sidebar [items]="sidebarItems" [footerItems]="sidebarFooterItems"></lfx-sidebar>
</div>

<!-- Sidebar - Mobile Overlay -->
@if (showMobileSidebar()) {
<div class="lg:hidden fixed inset-0 z-40 bg-black bg-opacity-50" data-testid="mobile-sidebar-overlay" (click)="closeMobileSidebar()">
<div class="absolute top-0 left-0 bottom-0 w-72 bg-white shadow-xl" (click)="$event.stopPropagation()" data-testid="mobile-sidebar">
<div class="flex items-center justify-between p-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Menu</h2>
<button
type="button"
class="hover:opacity-80 transition-opacity p-2"
(click)="closeMobileSidebar()"
aria-label="Close menu"
data-testid="mobile-sidebar-close">
<i class="fa-light fa-times text-gray-600 text-xl"></i>
</button>
</div>
<div class="overflow-y-auto h-[calc(100vh-4rem)]">
<lfx-sidebar [items]="sidebarItems" [footerItems]="sidebarFooterItems"></lfx-sidebar>
</div>
</div>
</div>
}

<!-- Main Content Area -->
<main class="flex-1 min-w-0 transition-all duration-300 lg:ml-72" data-testid="main-content">
<router-outlet />
</main>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

:host {
display: block;
}
106 changes: 106 additions & 0 deletions apps/lfx-one/src/app/layouts/main-layout/main-layout.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterModule } from '@angular/router';
import { AppService } from '@app/shared/services/app.service';
import { SidebarComponent } from '@components/sidebar/sidebar.component';
import { SidebarMenuItem } from '@lfx-one/shared/interfaces';
import { filter } from 'rxjs';

@Component({
selector: 'lfx-main-layout',
standalone: true,
imports: [CommonModule, RouterModule, SidebarComponent],
templateUrl: './main-layout.component.html',
styleUrl: './main-layout.component.scss',
})
export class MainLayoutComponent {
private readonly router = inject(Router);
private readonly appService = inject(AppService);

// Expose mobile sidebar state from service
protected readonly showMobileSidebar = this.appService.showMobileSidebar;

// Sidebar navigation items
protected readonly sidebarItems: SidebarMenuItem[] = [
{
label: 'Home',
icon: 'fa-light fa-house',
routerLink: '/',
},
{
label: 'My Meetings',
icon: 'fa-light fa-video',
routerLink: '/my-meetings',
disabled: true,
},
{
label: 'Project Health',
icon: 'fa-light fa-heart-pulse',
routerLink: '/project-health',
disabled: true,
},
{
label: 'Events & Community',
icon: 'fa-light fa-calendar',
routerLink: '/events-community',
disabled: true,
},
{
label: 'Training & Certification',
icon: 'fa-light fa-book-open',
routerLink: '/training-certification',
disabled: true,
},
];

// Sidebar footer items
protected readonly sidebarFooterItems: SidebarMenuItem[] = [
{
label: 'Documentation',
icon: 'fa-light fa-file-lines',
url: 'https://docs.lfx.linuxfoundation.org',
},
{
label: 'Submit a Ticket',
icon: 'fa-light fa-circle-question',
url: 'https://jira.linuxfoundation.org/plugins/servlet/theme/portal/4',
},
{
label: 'Changelog',
icon: 'fa-light fa-rectangle-history',
routerLink: '/changelog',
disabled: true,
},
{
label: 'Settings',
icon: 'fa-light fa-gear',
routerLink: '/settings',
disabled: true,
},
{
label: 'Profile',
icon: 'fa-light fa-user',
routerLink: '/profile',
},
];

public constructor() {
// Close mobile sidebar on navigation
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
takeUntilDestroyed()
)
.subscribe(() => {
this.appService.closeMobileSidebar();
});
}

public closeMobileSidebar(): void {
this.appService.closeMobileSidebar();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export class ProfileLayoutComponent {
const profile = this.profile();
if (!profile?.profile) return '';

return profile.profile.job_title || '';
return profile.profile.title || '';
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
<!-- SPDX-License-Identifier: MIT -->

<lfx-card data-testid="dashboard-my-meetings-card" styleClass="hover:shadow-lg transition-shadow h-full flex flex-col">
<ng-template #header>
<div class="flex items-center gap-3 p-6 border-b border-gray-100">
<div class="w-12 h-12 rounded-lg bg-gray-50 flex items-center justify-center">
<i class="fa-light fa-calendar-days text-2xl text-green-500"></i>
</div>
<h2 class="text-base font-display font-medium text-gray-900">My Meetings</h2>
</div>
</ng-template>

<div class="p-6 flex-1 flex flex-col">
<div class="space-y-4 flex-1" data-testid="dashboard-my-meetings-list">
@for (item of meetings(); track item.time) {
<div
class="p-4 bg-white border border-gray-200 rounded-lg hover:border-gray-300 transition-colors"
[attr.data-testid]="'dashboard-my-meetings-item-' + item.title">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center flex-shrink-0">
<i class="fa-light fa-calendar text-green-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900 text-sm">{{ item.title }}</div>
<div class="text-xs text-gray-500">{{ item.time }}</div>
</div>
</div>
<div class="flex items-center gap-2 justify-between sm:justify-start w-full sm:w-auto">
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-gray-100 text-xs text-gray-600" data-testid="dashboard-my-meetings-attendees">
<i class="fa-light fa-users text-xs"></i>
{{ item.attendees }}
</span>
<button
type="button"
(click)="handleJoinMeeting(item)"
class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md transition-colors min-h-[44px] sm:min-h-0"
data-testid="dashboard-my-meetings-join-button"
aria-label="Join meeting">
Join
</button>
</div>
</div>
</div>
}
</div>
</div>
</lfx-card>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

import { CommonModule } from '@angular/common';
import { Component, computed, inject, output } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MeetingService } from '@app/shared/services/meeting.service';
import { CardComponent } from '@components/card/card.component';

import type { Meeting, MeetingItem, MeetingOccurrence } from '@lfx-one/shared/interfaces';

@Component({
selector: 'lfx-my-meetings',
standalone: true,
imports: [CommonModule, CardComponent],
templateUrl: './my-meetings.component.html',
styleUrl: './my-meetings.component.scss',
})
export class MyMeetingsComponent {
private readonly meetingService = inject(MeetingService);
private readonly allMeetings = toSignal(this.meetingService.getMeetings(), { initialValue: [] });

public readonly joinMeeting = output<MeetingItem>();

protected readonly meetings = computed<MeetingItem[]>(() => {
const now = new Date();
const currentTime = now.getTime();
const buffer = 40 * 60 * 1000; // 40 minutes in milliseconds

const upcomingMeetings: Array<{ meeting: Meeting; occurrence: MeetingOccurrence; sortTime: number }> = [];

for (const meeting of this.allMeetings()) {
// Process occurrences if they exist
if (meeting.occurrences && meeting.occurrences.length > 0) {
for (const occurrence of meeting.occurrences) {
const startTime = new Date(occurrence.start_time).getTime();
const endTime = startTime + occurrence.duration * 60 * 1000 + buffer;

// Only include if meeting hasn't ended yet (including buffer)
if (endTime >= currentTime) {
upcomingMeetings.push({
meeting,
occurrence,
sortTime: startTime,
});
}
}
} else {
// Handle meetings without occurrences (single meetings)
const startTime = new Date(meeting.start_time).getTime();
const endTime = startTime + meeting.duration * 60 * 1000 + buffer;

// Only include if meeting hasn't ended yet (including buffer)
if (endTime >= currentTime) {
upcomingMeetings.push({
meeting,
occurrence: {
occurrence_id: '',
title: meeting.title,
description: meeting.description,
start_time: meeting.start_time,
duration: meeting.duration,
},
sortTime: startTime,
});
}
}
}

// Sort by earliest time first and limit to 5
return upcomingMeetings
.sort((a, b) => a.sortTime - b.sortTime)
.slice(0, 5)
.map((item) => ({
title: item.occurrence.title,
time: this.formatMeetingTime(item.occurrence.start_time),
attendees: item.meeting.individual_registrants_count + item.meeting.committee_members_count,
}));
});

public handleJoinMeeting(meeting: MeetingItem): void {
this.joinMeeting.emit(meeting);
}

private formatMeetingTime(startTime: string): string {
const meetingDate = new Date(startTime);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
const meetingDateOnly = new Date(meetingDate.getFullYear(), meetingDate.getMonth(), meetingDate.getDate());

const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});

const formattedTime = timeFormatter.format(meetingDate);

if (meetingDateOnly.getTime() === today.getTime()) {
return `Today, ${formattedTime}`;
} else if (meetingDateOnly.getTime() === tomorrow.getTime()) {
return `Tomorrow, ${formattedTime}`;
}
const dateFormatter = new Intl.DateTimeFormat('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric',
});
return `${dateFormatter.format(meetingDate)}, ${formattedTime}`;
}
}
Loading
Loading