Sistemato attività

This commit is contained in:
Lorenzo Sanesi
2026-07-26 03:19:11 +02:00
parent 8d1a3e0d18
commit de921abcd6
57 changed files with 3063 additions and 144 deletions
@@ -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);
},
});
}
}