Sistemato attività
This commit is contained in:
@@ -97,3 +97,119 @@
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notifiche {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.campanellina {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.campanellina:hover {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.campanellina-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-error, #d64545);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notifiche-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 19;
|
||||
}
|
||||
|
||||
.notifiche-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
width: 320px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-header);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.notifiche-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.notifiche-segna-tutte {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--color-primary-text-strong);
|
||||
}
|
||||
|
||||
.notifiche-vuoto {
|
||||
padding: 20px 14px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.notifica-item {
|
||||
cursor: pointer;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.notifica-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notifica-item:hover {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.notifica-item--non-letta {
|
||||
font-weight: 600;
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.notifica-item--non-letta::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-right: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<span class="brand-name">Scouthub</span>
|
||||
</a>
|
||||
<nav class="nav-items">
|
||||
@for (item of navItems; track item.path) {
|
||||
@for (item of navItems(); track item.path) {
|
||||
<a
|
||||
class="nav-item"
|
||||
[class.nav-item--active]="isActive(item.path)"
|
||||
@@ -28,6 +28,45 @@
|
||||
</nav>
|
||||
<div class="auth-section">
|
||||
@if (isAuthenticated()) {
|
||||
<div class="notifiche">
|
||||
<button
|
||||
type="button"
|
||||
class="campanellina"
|
||||
(click)="toggleCampanellina()"
|
||||
aria-label="Notifiche"
|
||||
>
|
||||
🔔
|
||||
@if (countNonLette() > 0) {
|
||||
<span class="campanellina-badge">{{ countBadge() }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (campanellinaAperta()) {
|
||||
<div class="notifiche-overlay" (click)="chiudiCampanellina()"></div>
|
||||
<div class="notifiche-dropdown">
|
||||
<div class="notifiche-header">
|
||||
<span>Notifiche</span>
|
||||
@if (countNonLette() > 0) {
|
||||
<span class="notifiche-segna-tutte" (click)="segnaTutteLette()">
|
||||
Segna tutte come lette
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (notifiche().length === 0) {
|
||||
<div class="notifiche-vuoto">Nessuna notifica</div>
|
||||
} @else {
|
||||
@for (notifica of notifiche(); track notifica.id) {
|
||||
<div
|
||||
class="notifica-item"
|
||||
[class.notifica-item--non-letta]="!notifica.letta"
|
||||
(click)="apriNotifica(notifica)"
|
||||
>
|
||||
<span class="notifica-messaggio">{{ notifica.messaggio }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<span class="auth-name">{{ displayName() }}</span>
|
||||
<div class="btn-secondary btn-secondary--sm" (click)="logout()">Esci</div>
|
||||
} @else {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { Location } from '@angular/common';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
|
||||
import { NavigationEnd, Router, RouterLink } from '@angular/router';
|
||||
import { filter } from 'rxjs';
|
||||
import Keycloak from 'keycloak-js';
|
||||
import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular';
|
||||
|
||||
import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles';
|
||||
import { Notifica } from '../../core/models/notifica.model';
|
||||
import { NotificheService } from '../../core/services/notifiche';
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const INTERVALLO_POLLING_MS = 30000;
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
imports: [RouterLink],
|
||||
@@ -21,9 +27,20 @@ export class Header {
|
||||
private readonly location = inject(Location);
|
||||
private readonly keycloak = inject(Keycloak);
|
||||
private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL);
|
||||
private readonly notificheService = inject(NotificheService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly currentUrl = signal(this.router.url);
|
||||
|
||||
readonly notifiche = signal<Notifica[]>([]);
|
||||
readonly countNonLette = signal(0);
|
||||
readonly campanellinaAperta = signal(false);
|
||||
|
||||
readonly countBadge = computed(() => {
|
||||
const count = this.countNonLette();
|
||||
return count > 9 ? '9+' : String(count);
|
||||
});
|
||||
|
||||
readonly isAuthenticated = computed(() => {
|
||||
this.keycloakEvent();
|
||||
return !!this.keycloak.authenticated;
|
||||
@@ -40,17 +57,46 @@ export class Header {
|
||||
return url.startsWith('/attivita/dettaglio') || url.startsWith('/nuova-attivita') || url.startsWith('/modifica-attivita');
|
||||
});
|
||||
|
||||
readonly navItems: NavItem[] = [
|
||||
{ path: '/', label: 'Home' },
|
||||
{ path: '/ricerca', label: 'Cerca' },
|
||||
{ path: '/mie-attivita', label: 'Le mie attività' },
|
||||
{ path: '/profilo', label: 'Profilo' },
|
||||
];
|
||||
readonly isModeratore = computed(() => {
|
||||
this.keycloakEvent();
|
||||
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
|
||||
});
|
||||
|
||||
readonly navItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{ path: '/', label: 'Home' },
|
||||
{ path: '/ricerca', label: 'Cerca' },
|
||||
{ path: '/mie-attivita', label: 'Le mie attività' },
|
||||
];
|
||||
if (this.isModeratore()) {
|
||||
items.push({ path: '/tassonomie', label: 'Tassonomie' });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe(() => {
|
||||
this.currentUrl.set(this.router.url);
|
||||
});
|
||||
|
||||
// Il polling parte/si ferma seguendo lo stato di autenticazione: non ha senso interrogare
|
||||
// l'endpoint privato delle notifiche per un utente non loggato.
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
effect(() => {
|
||||
if (this.isAuthenticated()) {
|
||||
this.aggiornaCountNonLette();
|
||||
intervalId = setInterval(() => this.aggiornaCountNonLette(), INTERVALLO_POLLING_MS);
|
||||
} else {
|
||||
this.notifiche.set([]);
|
||||
this.countNonLette.set(0);
|
||||
}
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
if (intervalId !== undefined) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isActive(path: string): boolean {
|
||||
@@ -68,4 +114,50 @@ export class Header {
|
||||
logout(): void {
|
||||
this.keycloak.logout({ redirectUri: window.location.origin + '/' });
|
||||
}
|
||||
|
||||
private aggiornaCountNonLette(): void {
|
||||
this.notificheService.getCountNonLette().subscribe({
|
||||
next: ({ count }) => this.countNonLette.set(count),
|
||||
});
|
||||
}
|
||||
|
||||
toggleCampanellina(): void {
|
||||
const apri = !this.campanellinaAperta();
|
||||
this.campanellinaAperta.set(apri);
|
||||
if (apri) {
|
||||
this.notificheService.getLista().subscribe({
|
||||
next: (lista) => this.notifiche.set(lista),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
chiudiCampanellina(): void {
|
||||
this.campanellinaAperta.set(false);
|
||||
}
|
||||
|
||||
apriNotifica(notifica: Notifica): void {
|
||||
if (!notifica.letta) {
|
||||
this.notificheService.segnaLetta(notifica.id).subscribe({
|
||||
next: () => {
|
||||
this.notifiche.update((lista) =>
|
||||
lista.map((n) => (n.id === notifica.id ? { ...n, letta: true } : n)),
|
||||
);
|
||||
this.countNonLette.update((count) => Math.max(0, count - 1));
|
||||
},
|
||||
});
|
||||
}
|
||||
this.campanellinaAperta.set(false);
|
||||
if (notifica.link) {
|
||||
this.router.navigateByUrl(notifica.link);
|
||||
}
|
||||
}
|
||||
|
||||
segnaTutteLette(): void {
|
||||
this.notificheService.segnaTutteLette().subscribe({
|
||||
next: () => {
|
||||
this.notifiche.update((lista) => lista.map((n) => ({ ...n, letta: true })));
|
||||
this.countNonLette.set(0);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user