import { DatePipe } from '@angular/common'; import { booleanAttribute, ChangeDetectionStrategy, Component, computed, DestroyRef, Directive, inject, input, signal, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import type { Observable } from 'rxjs'; import { AfricaniesIconComponent } from '@africanies/africanies-icons'; import type { AfricaniesMenuItem } from '../action-menu/menu-item'; import { AvatarComponent, AvatarMenuComponent } from '../avatar'; import { AfricaniesUiI18n } from '../i18n/africanies-ui-i18n'; import type { AfricaniesNotification, NotificationPageResult } from '../notifications'; import { NotificationDrawerService } from '../notifications'; import type { HeaderWeather } from './header-greeting.util'; import { pickHeaderGreeting } from './header-greeting.util'; import { headerWeatherIcon, headerWeatherLabel, loadHeaderWeather, } from './header-weather'; import { HEADER_WEATHER_CONFIG } from './header-weather.token'; /** Header density — affects clock visibility in narrow layouts. */ export type AppShellHeaderDensity = 'mobile' | 'tablet' | 'desktop'; /** * Marks projected content as extra leading chrome in {@link AppShellHeaderComponent}. */ @Directive({ selector: '[africaniesAppShellHeaderStart]', standalone: true, }) /** * Marks projected content as extra leading chrome in {@link AppShellHeaderComponent}. */ export class AppShellHeaderStartDirective {} /** * Marks projected content as extra trailing chrome in {@link AppShellHeaderComponent}. */ @Directive({ selector: '[africaniesAppShellHeaderEnd]', standalone: true, }) /** * Marks projected content as extra trailing chrome in {@link AppShellHeaderComponent}. */ export class AppShellHeaderEndDirective {} /** * Product header bar for {@link AppShellComponent}: a short time-of-day kicker * plus the given name, local weather, live clock, notification inbox drawer, * and avatar menu. * Breadcrumbs and Back belong in the content column via * {@link AppShellContentHeaderComponent}. * * @example * ```html * * ``` */ @Component({ selector: 'africanies-app-shell-header', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AfricaniesIconComponent, AvatarComponent, AvatarMenuComponent, DatePipe, ], template: `
@if (greeting(); as hello) {

{{ hello.kicker }}

{{ hello.name }}

}
@if (weather() || showClock()) { } @if (showNotifications() || userName()) {
@if (showNotifications()) { } @if (userName()) { @if (userMenuItems().length > 0) { } @else { } }
}
`, }) /** * Product header bar for {@link AppShellComponent}: a short time-of-day kicker plus the given name, local weather, live clock, notification inbox drawer, and avatar menu. Breadcrumbs and Back belong in the content column via {@link AppShellContentHeaderComponent}. */ export class AppShellHeaderComponent { private readonly notificationsDrawer = inject(NotificationDrawerService); private readonly destroyRef = inject(DestroyRef); private readonly i18n = inject(AfricaniesUiI18n); private readonly weatherConfig = inject(HEADER_WEATHER_CONFIG, { optional: true, }); /** Account display name for the avatar menu. */ readonly userName = input(null); /** * Given or full name used to pick a time-of-day greeting. */ readonly greetingName = input(null); /** Optional avatar image URL. */ readonly userAvatarSrc = input(null); /** Menu entries for the avatar overflow menu. */ readonly userMenuItems = input([]); /** Notifications listed in the drawer. */ readonly notifications = input([]); /** Drawer heading override. */ readonly notificationsTitle = input(undefined); /** Marks one notification read when the user taps View. */ readonly onNotificationMarkRead = input< ((id: string) => Observable | Promise | void) | undefined >(undefined); /** Marks every notification read from the drawer header. */ readonly onNotificationMarkAllRead = input< (() => Observable | Promise | void) | undefined >(undefined); /** Paginated inbox fetch for infinite scroll inside the drawer. */ readonly onNotificationLoadPage = input< | (( page: number, ) => Observable | Promise) | undefined >(undefined); /** When false, hides the live clock. */ readonly showClock = input(true, { transform: booleanAttribute }); /** When false, hides the bell control. */ readonly showNotifications = input(true, { transform: booleanAttribute }); /** * Layout density — reserved for compact header previews. * Defaults to `desktop`. */ readonly density = input('desktop'); /** Angular DatePipe format for the clock date line. */ readonly clockDateFormat = input('EEE, MMM d, y'); /** Angular DatePipe format for the clock time (minutes, no seconds). */ readonly clockFormat = input('h:mm a'); protected readonly now = signal(new Date()); protected readonly weather = signal(null); protected readonly nowIso = computed(() => this.now().toISOString()); protected readonly greeting = computed(() => { const raw = pickHeaderGreeting( this.greetingName(), this.now(), this.weather(), ); if (!raw) { return null; } return { name: raw.name, kicker: this.i18n.t(raw.kickerKey, raw.kicker), }; }); protected readonly weatherIcon = computed(() => { const forecast = this.weather(); if (!forecast) { return 'cloud-o'; } return headerWeatherIcon(forecast.kind, this.now().getHours()); }); protected readonly weatherTemp = computed(() => { const temp = this.weather()?.temperatureC; if (temp === undefined || !Number.isFinite(temp)) { return null; } return String(Math.round(temp)); }); protected readonly weatherCondition = computed(() => { const forecast = this.weather(); if (!forecast) { return ''; } const hour = this.now().getHours(); const fallback = headerWeatherLabel(forecast.kind, hour); if (forecast.kind === 'clear') { const night = hour >= 19 || hour < 6; return this.i18n.t( night ? 'africaniesUi.header.weather.clearNight' : 'africaniesUi.header.weather.clearDay', fallback, ); } return this.i18n.t( `africaniesUi.header.weather.${forecast.kind}`, fallback, ); }); protected readonly weatherCity = computed(() => { const city = this.weather()?.city?.trim(); return city || null; }); protected readonly weatherAriaLabel = computed(() => { const forecast = this.weather(); if (!forecast) { return this.i18n.t('africaniesUi.header.weather.aria', 'Weather'); } const condition = this.weatherCondition().toLowerCase(); const temp = this.weatherTemp(); const city = this.weatherCity(); if (city) { return temp === null ? this.i18n.tParams( 'africaniesUi.header.weather.ariaInCity', 'Weather in {{city}}: {{condition}}', { city, condition }, ) : this.i18n.tParams( 'africaniesUi.header.weather.ariaInCityTemp', 'Weather in {{city}}: {{temp}} degrees Celsius, {{condition}}', { city, temp, condition }, ); } return temp === null ? this.i18n.tParams( 'africaniesUi.header.weather.ariaCondition', 'Weather: {{condition}}', { condition }, ) : this.i18n.tParams( 'africaniesUi.header.weather.ariaConditionTemp', 'Weather: {{temp}} degrees Celsius, {{condition}}', { temp, condition }, ); }); protected readonly unreadCount = computed( () => (this.notifications() ?? []).filter((n) => !n.read).length, ); protected readonly unreadBadge = computed(() => { const count = this.unreadCount(); return count > 9 ? '9+' : `${count}`; }); protected readonly notificationAriaLabel = computed(() => { const unread = this.unreadCount(); if (unread === 0) { return this.i18n.t( 'africaniesUi.shell.openNotifications', 'Open notifications', ); } return this.i18n.tParams( 'africaniesUi.shell.openNotificationsUnread', 'Open notifications, {{count}} unread', { count: unread }, ); }); private resolvedNotificationsTitle(): string { return ( this.notificationsTitle() ?? this.i18n.t('africaniesUi.shell.notifications', 'Notifications') ); } constructor() { let active = true; const refreshWeather = (): void => { void loadHeaderWeather({ bigDataCloudApiKey: this.weatherConfig?.bigDataCloudApiKey, }).then((forecast) => { if (active && forecast) { this.weather.set(forecast); } }); }; refreshWeather(); const tick = setInterval(() => { const next = new Date(); const hourChanged = next.getHours() !== this.now().getHours() || next.getDate() !== this.now().getDate(); this.now.set(next); if (hourChanged) { refreshWeather(); } }, 1_000); this.destroyRef.onDestroy(() => { active = false; clearInterval(tick); }); } protected openNotifications(): void { const onMarkRead = this.onNotificationMarkRead(); const onMarkAllRead = this.onNotificationMarkAllRead(); const onLoadPage = this.onNotificationLoadPage(); this.notificationsDrawer .open({ title: this.resolvedNotificationsTitle(), notifications: this.notifications(), onLoadPage, onMarkRead, onMarkAllRead, }) .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(); } }