Add scouthub-attivita-fe

This commit is contained in:
Lorenzo Sanesi
2026-07-23 19:35:45 +02:00
parent 2ee7d402b6
commit cfd2de4ca3
61 changed files with 11437 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.angular
*.log
.git
.idea
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+47
View File
@@ -0,0 +1,47 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
/.vscode/
/.claude/
/bash.exe.stackdump
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist/scouthub-attivita-fe/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+59
View File
@@ -0,0 +1,59 @@
# ScouthubAttivitaFe
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.0.7.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+79
View File
@@ -0,0 +1,79 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"scouthub-attivita-fe": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "scouthub-attivita-fe:build:production"
},
"development": {
"buildTarget": "scouthub-attivita-fe:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+10
View File
@@ -0,0 +1,10 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "scouthub-attivita-fe",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.12.0",
"dependencies": {
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
"keycloak-angular": "^22.0.0",
"keycloak-js": "^26.2.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.0.7",
"@angular/cli": "^22.0.7",
"@angular/compiler-cli": "^22.0.0",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^4.0.8"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,7 @@
<html>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>
@@ -0,0 +1,33 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
createInterceptorCondition,
IncludeBearerTokenCondition,
includeBearerTokenInterceptor,
INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG
} from 'keycloak-angular';
import { routes } from './app.routes';
import { provideKeycloakAngular } from './core/auth/keycloak.provider';
import { environment } from '../environments/environment';
const escapedApiUrl = environment.apiUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const apiBearerCondition = createInterceptorCondition<IncludeBearerTokenCondition>({
urlPattern: new RegExp(`^${escapedApiUrl}(/.*)?$`, 'i'),
bearerPrefix: 'Bearer'
});
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideKeycloakAngular(),
{
provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
useValue: [apiBearerCondition]
},
provideHttpClient(withInterceptors([includeBearerTokenInterceptor]))
]
};
+3
View File
@@ -0,0 +1,3 @@
.app-shell {
min-height: 100vh;
}
+4
View File
@@ -0,0 +1,4 @@
<div class="app-shell">
<app-header />
<router-outlet />
</div>
@@ -0,0 +1,20 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/auth/auth.guard';
import { ActivityForm } from './pages/activity-form/activity-form';
import { Detail } from './pages/detail/detail';
import { Home } from './pages/home/home';
import { MyActivity } from './pages/my-activity/my-activity';
import { Profile } from './pages/profile/profile';
import { Search } from './pages/search/search';
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'ricerca', component: Search },
{ path: 'attivita/dettaglio/:idAttivita', component: Detail },
{ path: 'mie-attivita', component: MyActivity, canActivate: [authGuard] },
{ path: 'profilo', component: Profile, canActivate: [authGuard] },
{ path: 'nuova-attivita', component: ActivityForm, canActivate: [authGuard] },
{ path: 'modifica-attivita/:idAttivita', component: ActivityForm, canActivate: [authGuard] },
{ path: '**', redirectTo: '' },
];
+32
View File
@@ -0,0 +1,32 @@
import { provideHttpClient } from '@angular/common/http';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import Keycloak from 'keycloak-js';
import { KEYCLOAK_EVENT_SIGNAL, KeycloakEvent, KeycloakEventType } from 'keycloak-angular';
import { App } from './app';
import { routes } from './app.routes';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter(routes),
provideHttpClient(),
{ provide: Keycloak, useValue: { authenticated: false, tokenParsed: {} } },
{
provide: KEYCLOAK_EVENT_SIGNAL,
useValue: signal<KeycloakEvent>({ type: KeycloakEventType.Ready, args: true }),
},
],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Header } from './shared/header/header';
@Component({
selector: 'app-root',
imports: [RouterOutlet, Header],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
@@ -0,0 +1,19 @@
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot, UrlTree } from '@angular/router';
import { AuthGuardData, createAuthGuard } from 'keycloak-angular';
export async function isAccessAllowed(
_route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
authData: AuthGuardData
): Promise<boolean | UrlTree> {
const { authenticated, keycloak } = authData;
if (!authenticated) {
await keycloak.login({ redirectUri: window.location.origin + state.url });
return false;
}
return true;
}
export const authGuard: CanActivateFn = createAuthGuard<CanActivateFn>(isAccessAllowed);
@@ -0,0 +1,26 @@
import { EnvironmentProviders } from '@angular/core';
import { provideKeycloak, withAutoRefreshToken, AutoRefreshTokenService, UserActivityService } from 'keycloak-angular';
import { environment } from '../../../environments/environment';
export function provideKeycloakAngular(): EnvironmentProviders {
return provideKeycloak({
config: {
url: environment.keycloakBaseUrl,
realm: environment.keycloakRealm,
clientId: environment.keycloakClientId
},
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
redirectUri: window.location.origin + '/'
},
features: [
withAutoRefreshToken({
onInactivityTimeout: 'logout',
sessionTimeout: 300000
})
],
providers: [AutoRefreshTokenService, UserActivityService]
});
}
@@ -0,0 +1,69 @@
export interface BaseTipologica {
id: string;
nome: string;
}
export interface Stato extends BaseTipologica {}
export const STATO_BOZZA: Stato = { id: 'BO', nome: 'Bozza' };
export const STATO_PUBBLICATO: Stato = { id: 'PU', nome: 'Pubblicato' };
export const STATO_PRIVATO: Stato = { id: 'PR', nome: 'Privato' };
export const STATI: Stato[] = [STATO_BOZZA, STATO_PUBBLICATO, STATO_PRIVATO];
export interface TipoParagrafo extends BaseTipologica {}
export const TIPO_TITOLO: TipoParagrafo = { id: 'TITOLO', nome: 'Titolo' };
export const TIPO_PARAGRAFO: TipoParagrafo = { id: 'PARAGRAFO', nome: 'Paragrafo' };
export interface TipoCategoria extends BaseTipologica {}
export interface Branca {
id: number;
nome: string;
inizioEta?: number;
fineEta?: number;
colore?: string;
}
export interface Categoria {
id: number;
nome: string;
tipo?: TipoCategoria;
padre?: number | null;
}
export interface Materiale {
id: number;
nome: string;
proprieta?: string;
}
export interface PeriodoAnno {
id: number;
nome: string;
inizioMese?: number | null;
fineMese?: number | null;
}
export interface Paragrafo {
id?: number;
corpo: string;
tipo: TipoParagrafo;
ordine: number;
}
export interface Attivita {
id?: number;
nome: string;
autore: string;
stato: Stato;
padre?: number | null;
paragrafoList: Paragrafo[];
brancaList: Branca[];
categoriaList: Categoria[];
materialeList: Materiale[];
periodoAnnoList: PeriodoAnno[];
dataCreazione?: string;
dataModifica?: string;
utenteModifica?: string;
}
@@ -0,0 +1,21 @@
export type GruppoFiltro = 'testo' | 'branca' | 'categoria' | 'materiale' | 'periodoAnno';
export interface FiltroAttivo {
id: number | null;
nome: string;
gruppo: GruppoFiltro;
}
/** Corrisponde a SearchObjectDto lato backend: usato sia come voce inviata
* a POST /get/lista/search (in array) sia come elemento restituito dagli
* endpoint di autocomplete. */
export interface SearchObjectDto {
id: number | null;
nome: string | null;
gruppo: string;
}
export interface AutocompleteGroup {
label: string;
objectsList: SearchObjectDto[];
}
@@ -0,0 +1,40 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { Attivita, Stato } from '../models/attivita.model';
import { SearchObjectDto } from '../models/search.model';
@Injectable({
providedIn: 'root',
})
export class AttivitaService {
private readonly http = inject(HttpClient);
private readonly publicUrl = `${environment.apiUrl}/public/attivita`;
private readonly privateUrl = `${environment.apiUrl}/private/attivita`;
getListHome(): Observable<Attivita[]> {
return this.http.get<Attivita[]>(`${this.publicUrl}/get/lista/home`);
}
search(filtri: SearchObjectDto[]): Observable<Attivita[]> {
return this.http.post<Attivita[]>(`${this.publicUrl}/get/lista/search`, filtri);
}
getOne(id: number): Observable<Attivita> {
return this.http.get<Attivita>(`${this.publicUrl}/get/one/${id}`);
}
getListMy(): Observable<Attivita[]> {
return this.http.get<Attivita[]>(`${this.privateUrl}/get/lista/my`);
}
changeStato(idAttivita: number, idStato: string): Observable<Stato> {
return this.http.get<Stato>(`${this.privateUrl}/change/stato/${idAttivita}/${idStato}`);
}
save(attivita: Attivita): Observable<void> {
return this.http.post<void>(`${this.privateUrl}/save`, attivita);
}
}
@@ -0,0 +1,45 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { AutocompleteGroup, SearchObjectDto } from '../models/search.model';
const JSON_STRING_HEADERS = new HttpHeaders({ 'Content-Type': 'application/json' });
@Injectable({
providedIn: 'root',
})
export class AutocompleteService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/public/autocomplete`;
private postKeyword<T>(path: string, keyword: string): Observable<T> {
// Il backend si aspetta come body una stringa JSON nuda (es. "caccia"),
// non un oggetto: va serializzata esplicitamente per evitare che
// HttpClient la invii come text/plain non incapsulata.
return this.http.post<T>(`${this.baseUrl}${path}`, JSON.stringify(keyword), {
headers: JSON_STRING_HEADERS,
});
}
search(keyword: string): Observable<AutocompleteGroup[]> {
return this.postKeyword<AutocompleteGroup[]>('/get/search', keyword);
}
branca(keyword: string): Observable<SearchObjectDto[]> {
return this.postKeyword<SearchObjectDto[]>('/get/branca', keyword);
}
categoria(keyword: string): Observable<SearchObjectDto[]> {
return this.postKeyword<SearchObjectDto[]>('/get/categoria', keyword);
}
materiale(keyword: string): Observable<SearchObjectDto[]> {
return this.postKeyword<SearchObjectDto[]>('/get/materiale', keyword);
}
periodoAnno(keyword: string): Observable<SearchObjectDto[]> {
return this.postKeyword<SearchObjectDto[]>('/get/periodoAnno', keyword);
}
}
@@ -0,0 +1,30 @@
import { Injectable, signal } from '@angular/core';
import { FiltroAttivo } from '../models/search.model';
@Injectable({
providedIn: 'root',
})
export class NavigationService {
readonly filtri = signal<FiltroAttivo[]>([]);
readonly searchQuery = signal('');
addFiltro(filtro: FiltroAttivo): void {
const esiste = this.filtri().some(
(f) => f.gruppo === filtro.gruppo && f.id === filtro.id && f.nome === filtro.nome
);
if (esiste) {
return;
}
this.filtri.update((lista) => [...lista, filtro]);
}
removeFiltro(index: number): void {
this.filtri.update((lista) => lista.filter((_, i) => i !== index));
}
reset(): void {
this.filtri.set([]);
this.searchQuery.set('');
}
}
@@ -0,0 +1,38 @@
export interface StatoStyle {
bg: string;
color: string;
border: string;
}
export function statoStyle(idStato: string): StatoStyle {
if (idStato === 'PU') {
return { bg: 'var(--color-primary-soft)', color: 'var(--color-primary-text)', border: 'var(--color-primary-soft-border)' };
}
if (idStato === 'PR') {
return { bg: 'var(--color-periodo-bg)', color: 'var(--color-periodo-text)', border: 'var(--color-periodo-border)' };
}
return { bg: 'var(--color-neutral-bg)', color: 'var(--color-neutral-text)', border: 'var(--color-neutral-border)' };
}
export function formatData(data: string | undefined): string {
if (!data) {
return '';
}
return new Intl.DateTimeFormat('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(data));
}
export function parseMarkdown(testo: string): { text: string; weight: string; style: string }[] {
const parti = (testo || '').split(/(\*\*[^*]+\*\*|\*[^*]+\*)/g).filter((p) => p.length);
if (!parti.length) {
return [{ text: '', weight: '400', style: 'normal' }];
}
return parti.map((p) => {
if (p.startsWith('**') && p.endsWith('**')) {
return { text: p.slice(2, -2), weight: '700', style: 'normal' };
}
if (p.startsWith('*') && p.endsWith('*')) {
return { text: p.slice(1, -1), weight: '400', style: 'italic' };
}
return { text: p, weight: '400', style: 'normal' };
});
}
@@ -0,0 +1,202 @@
.wizard-page {
max-width: 760px;
padding-bottom: 80px;
}
.titolo {
font-size: 26px;
margin: 0 0 6px;
font-weight: 700;
}
.steps {
display: flex;
gap: 6px;
margin: 18px 0 28px;
flex-wrap: wrap;
}
.step-tab {
cursor: pointer;
flex: 1;
min-width: 100px;
text-align: center;
padding: 10px 8px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
background: var(--color-surface);
color: var(--color-text);
}
.step-tab--attivo {
background: var(--color-primary);
color: #fff;
}
.step-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.step-content--gap {
gap: 24px;
}
.field {
font-weight: 700;
font-size: 14px;
}
.field--sm {
font-size: 13px;
font-weight: 600;
color: var(--color-text-muted);
}
.text-input {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 6px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--color-border);
font-size: 16px;
font-family: inherit;
background: var(--color-surface);
color: var(--color-text);
}
.text-input--sm {
margin-top: 4px;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
}
.stato-choices {
display: flex;
gap: 8px;
margin-top: 6px;
}
.stato-choice {
cursor: pointer;
padding: 9px 16px;
border-radius: 8px;
font-weight: 700;
font-size: 14px;
border: 1px solid var(--color-border);
}
.paragrafo-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 14px 16px;
}
.paragrafo-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.paragrafo-tipo {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--color-primary-text-strong);
}
.paragrafo-azioni {
display: flex;
gap: 6px;
}
.azione {
cursor: pointer;
padding: 2px 8px;
}
.azione--danger {
color: var(--color-danger);
}
.formattazione {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.fmt-btn {
cursor: pointer;
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 13px;
background: var(--color-surface);
}
.fmt-btn--italic {
font-style: italic;
}
.paragrafo-textarea {
width: 100%;
box-sizing: border-box;
border: none;
background: transparent;
font-size: 16px;
font-family: inherit;
resize: vertical;
color: var(--color-text);
}
.aggiungi-paragrafo {
cursor: pointer;
text-align: center;
padding: 12px;
border-radius: 10px;
border: 1px dashed var(--color-border-strong);
font-weight: 600;
color: var(--color-text-muted);
}
.riepilogo {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 20px;
}
.riepilogo-nome {
font-weight: 700;
font-size: 19px;
margin-bottom: 6px;
}
.riepilogo-meta {
font-size: 14px;
color: var(--color-text-muted);
margin-bottom: 14px;
}
.riepilogo-linea {
font-size: 14px;
margin-top: 6px;
}
.wizard-nav {
display: flex;
justify-content: space-between;
margin-top: 28px;
}
@@ -0,0 +1,157 @@
<div class="page wizard-page">
<h1 class="titolo">{{ wizardTitle() }}</h1>
<div class="steps">
@for (label of stepLabels; track $index) {
<div
class="step-tab"
[class.step-tab--attivo]="step() === $index"
(click)="setStep($index)"
>
{{ $index + 1 }}. {{ label }}
</div>
}
</div>
@if (step() === 0) {
<div class="step-content">
<label class="field">
Nome attività
<input
class="text-input"
type="text"
[value]="nome()"
(input)="nome.set($any($event.target).value)"
placeholder="Es. Caccia al tesoro nel bosco"
/>
</label>
<label class="field">
Stato iniziale
<div class="stato-choices">
@for (s of stati; track s.id) {
<div
class="stato-choice"
[class.stato-choice--attivo]="stato().id === s.id"
[style.background]="stato().id === s.id ? statoStile(s.id).bg : 'transparent'"
[style.color]="stato().id === s.id ? statoStile(s.id).color : 'var(--color-text-muted)'"
[style.border-color]="stato().id === s.id ? statoStile(s.id).border : 'var(--color-border)'"
(click)="selectStato(s)"
>
{{ s.nome }}
</div>
}
</div>
</label>
</div>
}
@if (step() === 1) {
<div class="step-content step-content--gap">
<app-chip-field
label="Branca"
[chips]="brancaList()"
[(query)]="brancaQuery"
[suggestions]="brancaSuggestions()"
(select)="addBranca($event)"
(remove)="removeBranca($event)"
/>
<app-chip-field
label="Categoria"
[chips]="categoriaList()"
[(query)]="categoriaQuery"
[suggestions]="categoriaSuggestions()"
(select)="addCategoria($event)"
(remove)="removeCategoria($event)"
/>
<app-chip-field
label="Periodo dell'anno"
[chips]="periodoAnnoList()"
[(query)]="periodoQuery"
[suggestions]="periodoSuggestions()"
(select)="addPeriodo($event)"
(remove)="removePeriodo($event)"
/>
</div>
}
@if (step() === 2) {
<div class="step-content">
<app-chip-field
label="Materiale"
[chips]="materialeList()"
[(query)]="materialeQuery"
[suggestions]="materialeSuggestions()"
(select)="addMateriale($event)"
(remove)="removeMateriale($event)"
/>
@for (materiale of materialeList(); track materiale.id) {
<label class="field field--sm">
Note/proprietà per "{{ materiale.nome }}"
<input
class="text-input text-input--sm"
type="text"
[value]="materiale.proprieta || ''"
(input)="setMaterialeProprieta(materiale.id, $any($event.target).value)"
placeholder="es. quantità, note d'uso"
/>
</label>
}
</div>
}
@if (step() === 3) {
<div class="step-content">
@for (paragrafo of paragrafoList(); track $index; let i = $index) {
<div class="paragrafo-card">
<div class="paragrafo-header">
<span class="paragrafo-tipo">{{ paragrafo.tipo.id === 'TITOLO' ? 'Titolo' : 'Paragrafo ' + i }}</span>
@if (paragrafo.tipo.id !== 'TITOLO') {
<div class="paragrafo-azioni">
<span class="azione" (click)="moveParagrafo(i, -1)"></span>
<span class="azione" (click)="moveParagrafo(i, 1)"></span>
<span class="azione azione--danger" (click)="removeParagrafo(i)"></span>
</div>
}
</div>
@if (paragrafo.tipo.id !== 'TITOLO') {
<div class="formattazione">
<span class="fmt-btn" (click)="wrapSelection(ta, i, '**')">B</span>
<span class="fmt-btn fmt-btn--italic" (click)="wrapSelection(ta, i, '*')">I</span>
</div>
}
<textarea
#ta
class="paragrafo-textarea"
[value]="paragrafo.corpo"
(input)="updateParagrafoCorpo(i, $any($event.target).value)"
[rows]="paragrafo.tipo.id === 'TITOLO' ? 1 : 3"
[placeholder]="paragrafo.tipo.id === 'TITOLO' ? 'Titolo dell\'attività' : 'Scrivi il contenuto di questo paragrafo...'"
></textarea>
</div>
}
<div class="aggiungi-paragrafo" (click)="addParagrafo()">+ Aggiungi paragrafo</div>
</div>
}
@if (step() === 4) {
<div class="riepilogo">
<div class="riepilogo-nome">{{ nome() }}</div>
<div class="riepilogo-meta">Di {{ autore() }} · Stato: {{ stato().nome }}</div>
<div class="riepilogo-linea">{{ summaryLine() }}</div>
<div class="riepilogo-linea">
{{ nonTitoloCount() }} paragraf{{ nonTitoloCount() === 1 ? 'o' : 'i' }} oltre al titolo
</div>
</div>
}
<div class="wizard-nav">
<div class="btn-secondary" [style.visibility]="step() === 0 ? 'hidden' : 'visible'" (click)="prevStep()">
Indietro
</div>
@if (isLastStep()) {
<div class="btn-primary" (click)="salva()">Salva attività</div>
} @else {
<div class="btn-primary" (click)="nextStep()">Avanti</div>
}
</div>
</div>
@@ -0,0 +1,259 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import {
Attivita,
Branca,
Categoria,
Materiale,
Paragrafo,
PeriodoAnno,
STATI,
STATO_BOZZA,
Stato,
TIPO_PARAGRAFO,
TIPO_TITOLO,
} from '../../core/models/attivita.model';
import { AttivitaService } from '../../core/services/attivita';
import { AutocompleteService } from '../../core/services/autocomplete';
import { statoStyle } from '../../core/utils/stato-style';
import { ChipField, ChipOption } from '../../shared/chip-field/chip-field';
const STEP_LABELS = ['Info base', 'Classificazione', 'Materiale', 'Paragrafi', 'Riepilogo'];
@Component({
selector: 'app-activity-form',
imports: [ChipField],
templateUrl: './activity-form.html',
styleUrl: './activity-form.css',
})
export class ActivityForm {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly attivitaService = inject(AttivitaService);
private readonly autocompleteService = inject(AutocompleteService);
private readonly keycloak = inject(Keycloak);
readonly editingId = signal<number | null>(null);
readonly step = signal(0);
readonly stepLabels = STEP_LABELS;
readonly stati = STATI;
readonly nome = signal('');
readonly autore = signal(this.nomeUtenteAutenticato());
readonly stato = signal<Stato>(STATO_BOZZA);
readonly brancaList = signal<Branca[]>([]);
readonly categoriaList = signal<Categoria[]>([]);
readonly periodoAnnoList = signal<PeriodoAnno[]>([]);
readonly materialeList = signal<Materiale[]>([]);
readonly paragrafoList = signal<Paragrafo[]>([{ corpo: '', tipo: TIPO_TITOLO, ordine: 0 }]);
readonly brancaQuery = signal('');
readonly categoriaQuery = signal('');
readonly periodoQuery = signal('');
readonly materialeQuery = signal('');
readonly brancaSuggestions = signal<ChipOption[]>([]);
readonly categoriaSuggestions = signal<ChipOption[]>([]);
readonly periodoSuggestions = signal<ChipOption[]>([]);
readonly materialeSuggestions = signal<ChipOption[]>([]);
readonly wizardTitle = computed(() => (this.editingId() ? 'Modifica attività' : 'Nuova attività'));
readonly isLastStep = computed(() => this.step() === 4);
readonly nonTitoloCount = computed(() => this.paragrafoList().filter((p) => p.tipo.id !== 'TITOLO').length);
readonly summaryLine = computed(() => {
const nomi = [...this.brancaList().map((b) => b.nome), ...this.categoriaList().map((c) => c.nome)];
return nomi.length > 0 ? nomi.join(' · ') : 'Nessuna classificazione selezionata';
});
constructor() {
const idParam = this.route.snapshot.paramMap.get('idAttivita');
if (idParam) {
const id = Number(idParam);
this.editingId.set(id);
this.attivitaService.getOne(id).subscribe((attivita) => this.popolaForm(attivita));
}
effect(() => {
const query = this.brancaQuery();
const selezionati = new Set(this.brancaList().map((b) => b.id));
if (!query.trim()) {
this.brancaSuggestions.set([]);
return;
}
this.autocompleteService.branca(query).subscribe({
next: (r) => this.brancaSuggestions.set(this.toChipOptions(r, selezionati)),
error: () => this.brancaSuggestions.set([]),
});
});
effect(() => {
const query = this.categoriaQuery();
const selezionati = new Set(this.categoriaList().map((c) => c.id));
if (!query.trim()) {
this.categoriaSuggestions.set([]);
return;
}
this.autocompleteService.categoria(query).subscribe({
next: (r) => this.categoriaSuggestions.set(this.toChipOptions(r, selezionati)),
error: () => this.categoriaSuggestions.set([]),
});
});
effect(() => {
const query = this.periodoQuery();
const selezionati = new Set(this.periodoAnnoList().map((p) => p.id));
if (!query.trim()) {
this.periodoSuggestions.set([]);
return;
}
this.autocompleteService.periodoAnno(query).subscribe({
next: (r) => this.periodoSuggestions.set(this.toChipOptions(r, selezionati)),
error: () => this.periodoSuggestions.set([]),
});
});
effect(() => {
const query = this.materialeQuery();
const selezionati = new Set(this.materialeList().map((m) => m.id));
if (!query.trim()) {
this.materialeSuggestions.set([]);
return;
}
this.autocompleteService.materiale(query).subscribe({
next: (r) => this.materialeSuggestions.set(this.toChipOptions(r, selezionati)),
error: () => this.materialeSuggestions.set([]),
});
});
}
private toChipOptions(
risultati: { id: number | null; nome: string | null }[],
esclusi: Set<number>
): ChipOption[] {
return risultati
.filter((r): r is { id: number; nome: string } => r.id !== null && r.nome !== null)
.filter((r) => !esclusi.has(r.id));
}
private nomeUtenteAutenticato(): string {
const token = this.keycloak.tokenParsed;
return (token?.['name'] as string) ?? (token?.['preferred_username'] as string) ?? (token?.['email'] as string) ?? '';
}
private popolaForm(attivita: Attivita): void {
this.nome.set(attivita.nome);
this.autore.set(attivita.autore);
this.stato.set(attivita.stato);
this.brancaList.set([...attivita.brancaList]);
this.categoriaList.set([...attivita.categoriaList]);
this.periodoAnnoList.set([...attivita.periodoAnnoList]);
this.materialeList.set(attivita.materialeList.map((m) => ({ ...m })));
this.paragrafoList.set(attivita.paragrafoList.map((p) => ({ ...p })));
}
selectStato(stato: Stato): void {
this.stato.set(stato);
}
statoStile(idStato: string) {
return statoStyle(idStato);
}
addBranca(chip: ChipOption): void {
this.brancaList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
this.brancaQuery.set('');
}
removeBranca(chip: ChipOption): void {
this.brancaList.update((lista) => lista.filter((b) => b.id !== chip.id));
}
addCategoria(chip: ChipOption): void {
this.categoriaList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
this.categoriaQuery.set('');
}
removeCategoria(chip: ChipOption): void {
this.categoriaList.update((lista) => lista.filter((c) => c.id !== chip.id));
}
addPeriodo(chip: ChipOption): void {
this.periodoAnnoList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
this.periodoQuery.set('');
}
removePeriodo(chip: ChipOption): void {
this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== chip.id));
}
addMateriale(chip: ChipOption): void {
this.materialeList.update((lista) => [...lista, { id: chip.id, nome: chip.nome, proprieta: '' }]);
this.materialeQuery.set('');
}
removeMateriale(chip: ChipOption): void {
this.materialeList.update((lista) => lista.filter((m) => m.id !== chip.id));
}
setMaterialeProprieta(id: number, valore: string): void {
this.materialeList.update((lista) => lista.map((m) => (m.id === id ? { ...m, proprieta: valore } : m)));
}
addParagrafo(): void {
this.paragrafoList.update((lista) => [
...lista,
{ corpo: '', tipo: TIPO_PARAGRAFO, ordine: lista.length },
]);
}
removeParagrafo(idx: number): void {
this.paragrafoList.update((lista) =>
lista.filter((_, i) => i !== idx).map((p, i) => ({ ...p, ordine: i }))
);
}
moveParagrafo(idx: number, direzione: -1 | 1): void {
this.paragrafoList.update((lista) => {
const j = idx + direzione;
if (j < 1 || j >= lista.length) {
return lista;
}
const copia = [...lista];
[copia[idx], copia[j]] = [copia[j], copia[idx]];
return copia.map((p, i) => ({ ...p, ordine: i }));
});
}
updateParagrafoCorpo(idx: number, valore: string): void {
this.paragrafoList.update((lista) => lista.map((p, i) => (i === idx ? { ...p, corpo: valore } : p)));
}
wrapSelection(textarea: HTMLTextAreaElement, idx: number, marker: string): void {
const valore = this.paragrafoList()[idx].corpo || '';
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selezione = valore.slice(start, end) || 'testo';
const nuovoValore = valore.slice(0, start) + marker + selezione + marker + valore.slice(end);
this.updateParagrafoCorpo(idx, nuovoValore);
setTimeout(() => {
textarea.focus();
textarea.selectionStart = start + marker.length;
textarea.selectionEnd = start + marker.length + selezione.length;
});
}
nextStep(): void {
this.step.update((s) => Math.min(4, s + 1));
}
prevStep(): void {
this.step.update((s) => Math.max(0, s - 1));
}
setStep(n: number): void {
this.step.set(n);
}
salva(): void {
const attivita: Attivita = {
id: this.editingId() ?? undefined,
nome: this.nome(),
autore: this.autore(),
stato: this.stato(),
brancaList: this.brancaList(),
categoriaList: this.categoriaList(),
periodoAnnoList: this.periodoAnnoList(),
materialeList: this.materialeList(),
paragrafoList: this.paragrafoList(),
};
this.attivitaService.save(attivita).subscribe(() => this.router.navigate(['/mie-attivita']));
}
}
@@ -0,0 +1,125 @@
.page {
max-width: 760px;
padding-top: 36px;
}
.branca-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.branca-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
}
.branca-nomi {
font-size: 14px;
color: var(--color-text-muted);
}
.titolo {
font-size: 32px;
margin: 6px 0 8px;
font-weight: 700;
}
.meta {
font-size: 15px;
color: var(--color-text-muted);
margin-bottom: 16px;
}
.stato {
font-weight: 700;
}
.chips-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 28px;
}
.chip {
padding: 6px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
}
.chip--categoria {
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.chip--periodo {
background: var(--color-periodo-bg);
border: 1px solid var(--color-periodo-border);
color: var(--color-periodo-text);
}
.materiale-box {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 16px 20px;
margin-bottom: 28px;
}
.materiale-title {
font-weight: 700;
font-size: 15px;
margin-bottom: 10px;
}
.materiale-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.materiale-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
}
.materiale-icon {
color: var(--color-primary-text-strong);
}
.materiale-nome {
font-weight: 600;
}
.materiale-proprieta {
color: var(--color-text-muted);
}
.paragrafi {
display: flex;
flex-direction: column;
gap: 18px;
}
.paragrafo-titolo {
font-size: 22px;
font-weight: 700;
margin: 8px 0 0;
border-bottom: 2px solid var(--color-primary);
padding-bottom: 8px;
}
.paragrafo-corpo {
font-size: 17px;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
}
@@ -0,0 +1,58 @@
@if (attivita(); as a) {
<div class="page">
<div class="branca-row">
@for (branca of a.brancaList; track branca.id) {
<span class="branca-dot" [style.background]="branca.colore" [title]="branca.nome"></span>
}
<span class="branca-nomi">{{ brancaNomi() }}</span>
</div>
<h1 class="titolo">{{ a.nome }}</h1>
<div class="meta">
Di {{ a.autore }} · Aggiornata il {{ dataModificaFmt() }} ·
<span class="stato" [style.color]="statoTextColor()">{{ a.stato.nome }}</span>
</div>
<div class="chips-row">
@for (categoria of a.categoriaList; track categoria.id) {
<span class="chip chip--categoria">{{ categoria.nome }}</span>
}
@for (periodo of a.periodoAnnoList; track periodo.id) {
<span class="chip chip--periodo">{{ periodo.nome }}</span>
}
</div>
@if (a.materialeList.length > 0) {
<div class="materiale-box">
<div class="materiale-title">Materiale occorrente</div>
<div class="materiale-list">
@for (materiale of a.materialeList; track materiale.id) {
<div class="materiale-item">
<span class="materiale-icon">🧰</span>
<span class="materiale-nome">{{ materiale.nome }}</span>
@if (materiale.proprieta) {
<span class="materiale-proprieta">— {{ materiale.proprieta }}</span>
}
</div>
}
</div>
</div>
}
<div class="paragrafi">
@for (paragrafo of paragrafiOrdinati(); track paragrafo.id) {
@if (isTitolo(paragrafo)) {
<h2 class="paragrafo-titolo">{{ paragrafo.corpo }}</h2>
} @else {
<p class="paragrafo-corpo">
@for (token of tokens(paragrafo); track $index) {
<span [style.font-weight]="token.weight" [style.font-style]="token.style">{{ token.text }}</span>
}
</p>
}
}
</div>
</div>
}
@if (!attivita() && !caricamento()) {
<div class="page empty-state">Attività non trovata.</div>
}
@@ -0,0 +1,46 @@
import { Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Attivita, Paragrafo } from '../../core/models/attivita.model';
import { AttivitaService } from '../../core/services/attivita';
import { formatData, parseMarkdown, statoStyle } from '../../core/utils/stato-style';
@Component({
selector: 'app-detail',
imports: [],
templateUrl: './detail.html',
styleUrl: './detail.css',
})
export class Detail {
private readonly route = inject(ActivatedRoute);
private readonly attivitaService = inject(AttivitaService);
readonly attivita = signal<Attivita | null>(null);
readonly caricamento = signal(true);
readonly dataModificaFmt = computed(() => formatData(this.attivita()?.dataModifica));
readonly statoTextColor = computed(() => statoStyle(this.attivita()?.stato.id ?? 'BO').color);
readonly brancaNomi = computed(() => (this.attivita()?.brancaList ?? []).map((b) => b.nome).join(', '));
readonly paragrafiOrdinati = computed(() =>
[...(this.attivita()?.paragrafoList ?? [])].sort((a, b) => a.ordine - b.ordine)
);
constructor() {
const idAttivita = Number(this.route.snapshot.paramMap.get('idAttivita'));
this.attivitaService.getOne(idAttivita).subscribe({
next: (attivita) => {
this.attivita.set(attivita);
this.caricamento.set(false);
},
error: () => this.caricamento.set(false),
});
}
isTitolo(paragrafo: Paragrafo): boolean {
return paragrafo.tipo.id === 'TITOLO';
}
tokens(paragrafo: Paragrafo) {
return parseMarkdown(paragrafo.corpo);
}
}
@@ -0,0 +1,24 @@
<div class="page">
<div class="page-header">
<h1 class="page-title">Attività pubblicate</h1>
<a class="search-bar" routerLink="/ricerca">
<span>🔍</span><span>Cerca un'attività...</span>
</a>
</div>
@if (!caricamento() && attivitaList().length > 0) {
<div class="grid">
@for (attivita of attivitaList(); track attivita.id) {
<app-activity-card [attivita]="attivita" />
}
</div>
}
@if (!caricamento() && attivitaList().length === 0) {
<div class="empty-state">
<div class="empty-icon">🏕️</div>
<div class="empty-title">Nessuna attività pubblicata</div>
<div class="empty-subtitle">Le attività pubblicate dai capi compariranno qui.</div>
</div>
}
</div>
@@ -0,0 +1,29 @@
import { Component, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Attivita } from '../../core/models/attivita.model';
import { AttivitaService } from '../../core/services/attivita';
import { ActivityCard } from '../../shared/activity-card/activity-card';
@Component({
selector: 'app-home',
imports: [RouterLink, ActivityCard],
templateUrl: './home.html',
styleUrl: './home.css',
})
export class Home {
private readonly attivitaService = inject(AttivitaService);
readonly attivitaList = signal<Attivita[]>([]);
readonly caricamento = signal(true);
constructor() {
this.attivitaService.getListHome().subscribe({
next: (lista) => {
this.attivitaList.set(lista);
this.caricamento.set(false);
},
error: () => this.caricamento.set(false),
});
}
}
@@ -0,0 +1,57 @@
.lista {
display: flex;
flex-direction: column;
gap: 14px;
}
.riga {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 18px 20px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
.riga-info {
flex: 1;
min-width: 220px;
}
.riga-titolo {
cursor: pointer;
font-weight: 700;
font-size: 17px;
color: var(--color-text);
}
.riga-data {
font-size: 13px;
color: var(--color-text-muted);
margin-top: 2px;
}
.stato-options {
display: flex;
gap: 6px;
}
.stato-option {
cursor: pointer;
padding: 7px 13px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
border: 1px solid var(--color-border);
}
.riga-modifica {
cursor: pointer;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-border);
font-weight: 600;
font-size: 14px;
}
@@ -0,0 +1,39 @@
<div class="page">
<div class="page-header">
<h1 class="page-title page-title--sm">Le mie attività</h1>
<a class="btn-primary" routerLink="/nuova-attivita">+ Nuova attività</a>
</div>
@if (attivitaList().length > 0) {
<div class="lista">
@for (attivita of attivitaList(); track attivita.id) {
<div class="riga">
<div class="riga-info">
<a class="riga-titolo" [routerLink]="['/attivita/dettaglio', attivita.id]">{{ attivita.nome }}</a>
<div class="riga-data">Aggiornata il {{ dataModificaFmt(attivita) }}</div>
</div>
<div class="stato-options">
@for (stato of stati; track stato.id) {
<div
class="stato-option"
[class.stato-option--attivo]="isStatoAttivo(attivita, stato)"
[style.background]="isStatoAttivo(attivita, stato) ? stileStato(stato.id).bg : 'transparent'"
[style.color]="isStatoAttivo(attivita, stato) ? stileStato(stato.id).color : 'var(--color-text-muted)'"
[style.border-color]="isStatoAttivo(attivita, stato) ? stileStato(stato.id).border : 'var(--color-border)'"
(click)="cambiaStato(attivita, stato)"
>
{{ stato.nome }}
</div>
}
</div>
<div class="riga-modifica" (click)="modifica(attivita)">Modifica</div>
</div>
}
</div>
} @else if (!caricamento()) {
<div class="empty-state">
<div class="empty-icon">📋</div>
<div class="empty-title">Non hai ancora creato attività</div>
</div>
}
</div>
@@ -0,0 +1,58 @@
import { Component, inject, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { Attivita, STATI, Stato } from '../../core/models/attivita.model';
import { AttivitaService } from '../../core/services/attivita';
import { formatData, statoStyle } from '../../core/utils/stato-style';
@Component({
selector: 'app-my-activity',
imports: [RouterLink],
templateUrl: './my-activity.html',
styleUrl: './my-activity.css',
})
export class MyActivity {
private readonly attivitaService = inject(AttivitaService);
private readonly router = inject(Router);
readonly attivitaList = signal<Attivita[]>([]);
readonly caricamento = signal(true);
readonly stati = STATI;
constructor() {
this.caricaLista();
}
private caricaLista(): void {
this.attivitaService.getListMy().subscribe({
next: (lista) => {
this.attivitaList.set(lista);
this.caricamento.set(false);
},
error: () => this.caricamento.set(false),
});
}
dataModificaFmt(attivita: Attivita): string {
return formatData(attivita.dataModifica);
}
stileStato(idStato: string) {
return statoStyle(idStato);
}
isStatoAttivo(attivita: Attivita, stato: Stato): boolean {
return attivita.stato.id === stato.id;
}
cambiaStato(attivita: Attivita, stato: Stato): void {
if (!attivita.id || attivita.stato.id === stato.id) {
return;
}
this.attivitaService.changeStato(attivita.id, stato.id).subscribe(() => this.caricaLista());
}
modifica(attivita: Attivita): void {
this.router.navigate(['/modifica-attivita', attivita.id]);
}
}
@@ -0,0 +1,30 @@
.profile-page {
max-width: 520px;
padding-top: 60px;
text-align: center;
}
.avatar {
width: 84px;
height: 84px;
border-radius: 50%;
background: var(--color-primary-soft);
color: var(--color-primary-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 34px;
font-weight: 700;
margin: 0 auto 18px;
}
.titolo {
font-size: 24px;
margin: 0 0 6px;
font-weight: 700;
}
.sottotitolo {
color: var(--color-text-muted);
margin: 0 0 24px;
}
@@ -0,0 +1,8 @@
<div class="page profile-page">
<div class="avatar">👤</div>
<h1 class="titolo">Il tuo profilo</h1>
<p class="sottotitolo">
La gestione account arriverà con l'autenticazione. Per ora, accedi alle tue attività qui sotto.
</p>
<a class="btn-primary" routerLink="/mie-attivita">Vai a Le mie attività</a>
</div>
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-profile',
imports: [RouterLink],
templateUrl: './profile.html',
styleUrl: './profile.css',
})
export class Profile {}
@@ -0,0 +1,85 @@
.page-title {
font-size: 28px;
margin: 0 0 18px;
font-weight: 700;
}
.search-input-wrap {
position: relative;
max-width: 640px;
}
.search-input {
width: 100%;
box-sizing: border-box;
padding: 14px 16px;
font-size: 16px;
border-radius: 10px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
}
.suggestions {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
box-shadow: var(--shadow-menu);
z-index: 5;
max-height: 360px;
overflow: auto;
}
.suggestion-group-label {
padding: 10px 16px 4px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.suggestion-item {
padding: 10px 16px;
cursor: pointer;
font-size: 15px;
}
.suggestion-item:hover {
background: var(--color-primary-soft);
}
.active-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 16px 0 28px;
}
.filter-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
background: var(--color-primary-soft);
border: 1px solid var(--color-primary-soft-border);
font-size: 14px;
font-weight: 600;
color: var(--color-primary-text);
}
.filter-remove {
cursor: pointer;
font-weight: 700;
}
.results-label {
font-weight: 700;
color: var(--color-text-muted);
margin-bottom: 12px;
}
@@ -0,0 +1,47 @@
<div class="page">
<h1 class="page-title">Cerca attività</h1>
<div class="search-input-wrap">
<input
class="search-input"
type="text"
[value]="searchQuery()"
(input)="onQueryChange($event)"
(keydown)="onQueryKeyDown($event)"
placeholder="Cerca per nome, branca, categoria, materiale, periodo..."
/>
@if (showSuggestions()) {
<div class="suggestions">
@for (group of suggestionGroups(); track group.label) {
<div class="suggestion-group-label">{{ group.label }}</div>
@for (suggestion of group.objectsList; track suggestion.id) {
<div class="suggestion-item" (click)="selectSuggestion(suggestion)">
{{ suggestion.nome }}
</div>
}
}
</div>
}
</div>
<div class="active-filters">
@for (filtro of activeFilters(); track $index) {
<div class="filter-chip">
<span>{{ filtroLabel(filtro.gruppo) }}: {{ filtro.nome }}</span>
<span class="filter-remove" (click)="removeFiltro($index)">×</span>
</div>
}
</div>
<div class="results-label">{{ resultsLabel() }}</div>
@if (results().length > 0) {
<div class="grid">
@for (attivita of results(); track attivita.id) {
<app-activity-card [attivita]="attivita" />
}
</div>
} @else {
<div class="empty-state">Nessun risultato con questi filtri.</div>
}
</div>
@@ -0,0 +1,92 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { Attivita } from '../../core/models/attivita.model';
import { AutocompleteGroup, GruppoFiltro, SearchObjectDto } from '../../core/models/search.model';
import { AttivitaService } from '../../core/services/attivita';
import { AutocompleteService } from '../../core/services/autocomplete';
import { NavigationService } from '../../core/services/navigation';
import { ActivityCard } from '../../shared/activity-card/activity-card';
@Component({
selector: 'app-search',
imports: [ActivityCard],
templateUrl: './search.html',
styleUrl: './search.css',
})
export class Search {
private readonly attivitaService = inject(AttivitaService);
private readonly autocompleteService = inject(AutocompleteService);
readonly navigationService = inject(NavigationService);
readonly searchQuery = this.navigationService.searchQuery;
readonly activeFilters = this.navigationService.filtri;
readonly suggestionGroups = signal<AutocompleteGroup[]>([]);
readonly showSuggestions = computed(() => this.suggestionGroups().length > 0);
readonly results = signal<Attivita[]>([]);
readonly resultsLabel = computed(() => {
const n = this.results().length;
return `${n} risultat${n === 1 ? 'o' : 'i'}`;
});
constructor() {
effect(() => {
const filtri: SearchObjectDto[] = this.activeFilters();
this.attivitaService.search(filtri).subscribe({
next: (lista) => this.results.set(lista),
error: () => this.results.set([]),
});
});
}
onQueryChange(event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.searchQuery.set(value);
if (!value.trim()) {
this.suggestionGroups.set([]);
return;
}
this.autocompleteService.search(value).subscribe({
next: (groups) => this.suggestionGroups.set(groups),
error: () => this.suggestionGroups.set([]),
});
}
onQueryKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' && this.searchQuery().trim()) {
this.navigationService.addFiltro({ id: null, nome: this.searchQuery().trim(), gruppo: 'testo' });
this.searchQuery.set('');
this.suggestionGroups.set([]);
}
}
selectSuggestion(suggestion: SearchObjectDto): void {
const nome = suggestion.nome ?? this.searchQuery().trim();
if (!nome) {
return;
}
this.navigationService.addFiltro({
id: suggestion.id,
nome,
gruppo: suggestion.gruppo as GruppoFiltro,
});
this.searchQuery.set('');
this.suggestionGroups.set([]);
}
removeFiltro(index: number): void {
this.navigationService.removeFiltro(index);
}
filtroLabel(gruppo: string): string {
const etichette: Record<string, string> = {
testo: 'Testo',
branca: 'Branca',
categoria: 'Categoria',
materiale: 'Materiale',
periodoAnno: 'Periodo',
};
return etichette[gruppo] ?? gruppo;
}
}
@@ -0,0 +1,67 @@
.card {
cursor: pointer;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--color-text);
height: 100%;
box-sizing: border-box;
}
.card-top {
display: flex;
align-items: center;
gap: 6px;
}
.branca-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
}
.stato-badge {
margin-left: auto;
font-size: 12px;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
}
.card-title {
font-weight: 700;
font-size: 17px;
line-height: 1.3;
}
.card-meta {
font-size: 14px;
color: var(--color-text-muted);
}
.card-preview {
font-size: 14px;
color: oklch(38% 0.02 55);
line-height: 1.5;
flex: 1;
}
.card-chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.chip {
padding: 4px 10px;
border-radius: 999px;
background: var(--color-header);
border: 1px solid var(--color-border);
font-size: 12px;
font-weight: 600;
}
@@ -0,0 +1,22 @@
<a class="card" [routerLink]="['/attivita/dettaglio', attivita().id]">
<div class="card-top">
@for (branca of attivita().brancaList; track branca.id) {
<span class="branca-dot" [style.background]="branca.colore" [title]="branca.nome"></span>
}
<span
class="stato-badge"
[style.background]="stile().bg"
[style.color]="stile().color"
>
{{ attivita().stato.nome }}
</span>
</div>
<div class="card-title">{{ attivita().nome }}</div>
<div class="card-meta">Di {{ attivita().autore }} · {{ dataModificaFmt() }}</div>
<div class="card-preview">{{ preview() }}</div>
<div class="card-chips">
@for (categoria of attivita().categoriaList; track categoria.id) {
<span class="chip">{{ categoria.nome }}</span>
}
</div>
</a>
@@ -0,0 +1,26 @@
import { Component, computed, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Attivita } from '../../core/models/attivita.model';
import { formatData, statoStyle } from '../../core/utils/stato-style';
@Component({
selector: 'app-activity-card',
imports: [RouterLink],
templateUrl: './activity-card.html',
styleUrl: './activity-card.css',
})
export class ActivityCard {
readonly attivita = input.required<Attivita>();
readonly dataModificaFmt = computed(() => formatData(this.attivita().dataModifica));
readonly preview = computed(() => {
const paragrafi = this.attivita().paragrafoList ?? [];
const paragrafo = paragrafi.find((p) => p.tipo.id === 'PARAGRAFO');
const titolo = paragrafi.find((p) => p.tipo.id === 'TITOLO');
return paragrafo?.corpo ?? titolo?.corpo ?? '';
});
readonly stile = computed(() => statoStyle(this.attivita().stato.id));
}
@@ -0,0 +1,74 @@
.chip-field {
font-family: inherit;
}
.field-label {
font-weight: 700;
font-size: 14px;
}
.field-input-wrap {
position: relative;
margin-top: 6px;
}
.field-input {
width: 100%;
box-sizing: border-box;
padding: 11px 14px;
border-radius: 10px;
border: 1px solid var(--color-border);
font-size: 15px;
font-family: inherit;
background: var(--color-surface);
color: var(--color-text);
}
.suggestions {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
box-shadow: var(--shadow-menu);
z-index: 4;
max-height: 220px;
overflow: auto;
}
.suggestion-item {
padding: 9px 14px;
cursor: pointer;
font-size: 14px;
}
.suggestion-item:hover {
background: var(--color-primary-soft);
}
.chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
.chip {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: var(--color-primary-soft);
border: 1px solid var(--color-primary-soft-border);
font-size: 14px;
font-weight: 600;
color: var(--color-primary-text);
}
.chip-remove {
cursor: pointer;
font-weight: 700;
}
@@ -0,0 +1,27 @@
<div class="chip-field">
<label class="field-label">{{ label() }}</label>
<div class="field-input-wrap">
<input
class="field-input"
type="text"
placeholder="Cerca..."
[value]="query()"
(input)="onQueryInput($event)"
/>
@if (suggestions().length > 0) {
<div class="suggestions">
@for (suggestion of suggestions(); track suggestion.id) {
<div class="suggestion-item" (click)="select.emit(suggestion)">{{ suggestion.nome }}</div>
}
</div>
}
</div>
<div class="chips">
@for (chip of chips(); track chip.id) {
<div class="chip">
<span>{{ chip.nome }}</span>
<span class="chip-remove" (click)="remove.emit(chip)">×</span>
</div>
}
</div>
</div>
@@ -0,0 +1,26 @@
import { Component, input, model, output } from '@angular/core';
export interface ChipOption {
id: number;
nome: string;
}
@Component({
selector: 'app-chip-field',
imports: [],
templateUrl: './chip-field.html',
styleUrl: './chip-field.css',
})
export class ChipField {
readonly label = input.required<string>();
readonly chips = input.required<ChipOption[]>();
readonly suggestions = input<ChipOption[]>([]);
readonly query = model('');
readonly remove = output<ChipOption>();
readonly select = output<ChipOption>();
onQueryInput(event: Event): void {
this.query.set((event.target as HTMLInputElement).value);
}
}
@@ -0,0 +1,99 @@
.header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
padding: 16px 24px;
background: var(--color-header);
border-bottom: 1px solid var(--color-border);
}
.back-link {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--color-text);
}
.back-arrow {
font-size: 20px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text);
}
.brand--right {
margin-left: auto;
}
.logo {
width: 34px;
height: 34px;
border-radius: 8px;
background: var(--color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
}
.header--back .logo {
width: 32px;
height: 32px;
}
.brand-name {
font-weight: 700;
font-size: 19px;
letter-spacing: 0.2px;
}
.nav-items {
display: flex;
gap: 4px;
margin-left: auto;
flex-wrap: wrap;
}
.nav-item {
cursor: pointer;
padding: 8px 14px;
border-radius: 8px;
font-weight: 600;
font-size: 15px;
color: var(--color-text);
background: transparent;
}
.nav-item--active {
color: var(--color-primary-text-strong);
background: var(--color-primary-soft);
}
.auth-section {
display: flex;
align-items: center;
gap: 10px;
}
.auth-name {
font-weight: 600;
font-size: 14px;
color: var(--color-text);
}
.btn-secondary--sm {
padding: 6px 12px;
font-size: 13px;
}
@@ -0,0 +1,38 @@
@if (isDetailOrForm()) {
<div class="header header--back">
<div class="back-link" (click)="back()">
<span class="back-arrow"></span>
<span>Indietro</span>
</div>
<div class="brand brand--right">
<div class="logo">S</div>
<span class="brand-name">Scouthub</span>
</div>
</div>
} @else {
<div class="header header--nav">
<a class="brand" routerLink="/">
<div class="logo">S</div>
<span class="brand-name">Scouthub</span>
</a>
<nav class="nav-items">
@for (item of navItems; track item.path) {
<a
class="nav-item"
[class.nav-item--active]="isActive(item.path)"
[routerLink]="item.path"
>
{{ item.label }}
</a>
}
</nav>
<div class="auth-section">
@if (isAuthenticated()) {
<span class="auth-name">{{ displayName() }}</span>
<div class="btn-secondary btn-secondary--sm" (click)="logout()">Esci</div>
} @else {
<div class="btn-secondary btn-secondary--sm" (click)="login()">Accedi</div>
}
</div>
</div>
}
@@ -0,0 +1,71 @@
import { Location } from '@angular/common';
import { Component, computed, 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';
interface NavItem {
path: string;
label: string;
}
@Component({
selector: 'app-header',
imports: [RouterLink],
templateUrl: './header.html',
styleUrl: './header.css',
})
export class Header {
private readonly router = inject(Router);
private readonly location = inject(Location);
private readonly keycloak = inject(Keycloak);
private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL);
readonly currentUrl = signal(this.router.url);
readonly isAuthenticated = computed(() => {
this.keycloakEvent();
return !!this.keycloak.authenticated;
});
readonly displayName = computed(() => {
this.keycloakEvent();
const token = this.keycloak.tokenParsed;
return (token?.['name'] as string) ?? (token?.['preferred_username'] as string) ?? '';
});
readonly isDetailOrForm = computed(() => {
const url = this.currentUrl();
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' },
];
constructor() {
this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe(() => {
this.currentUrl.set(this.router.url);
});
}
isActive(path: string): boolean {
return path === '/' ? this.currentUrl() === '/' : this.currentUrl().startsWith(path);
}
back(): void {
this.location.back();
}
login(): void {
this.keycloak.login({ redirectUri: window.location.href });
}
logout(): void {
this.keycloak.logout({ redirectUri: window.location.origin + '/' });
}
}
@@ -0,0 +1,7 @@
export const environment = {
production: false,
apiUrl: 'http://localhost:8080',
keycloakBaseUrl: 'http://localhost:8081',
keycloakRealm: 'scouthub',
keycloakClientId: 'scouthub-frontend',
};
@@ -0,0 +1,7 @@
export const environment = {
production: true,
apiUrl: 'http://localhost:8080',
keycloakBaseUrl: 'http://localhost:8081',
keycloakRealm: 'scouthub',
keycloakClientId: 'scouthub-frontend',
};
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ScouthubAttivitaFe</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
+155
View File
@@ -0,0 +1,155 @@
@import url('https://fonts.cdnfonts.com/css/opendyslexic');
:root {
--color-bg: oklch(96% 0.018 75);
--color-surface: oklch(99% 0.008 75);
--color-header: oklch(94% 0.02 75);
--color-border: oklch(87% 0.02 70);
--color-border-strong: oklch(80% 0.02 70);
--color-text: oklch(24% 0.02 55);
--color-text-muted: oklch(48% 0.02 55);
--color-primary: oklch(63% 0.19 40);
--color-primary-hover: oklch(56% 0.19 40);
--color-primary-soft: oklch(93% 0.06 45);
--color-primary-soft-border: oklch(80% 0.09 45);
--color-primary-text: oklch(40% 0.15 40);
--color-primary-text-strong: oklch(48% 0.17 40);
--color-periodo-bg: oklch(93% 0.02 260);
--color-periodo-border: oklch(80% 0.04 260);
--color-periodo-text: oklch(35% 0.05 260);
--color-danger: oklch(50% 0.15 25);
--color-neutral-bg: oklch(92% 0.005 60);
--color-neutral-text: oklch(45% 0.02 55);
--color-neutral-border: oklch(85% 0.01 60);
--shadow-menu: 0 8px 24px oklch(30% 0.02 60 / 0.12);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background: var(--color-bg);
font-family: 'Open Dyslexic', 'Segoe UI', sans-serif;
color: var(--color-text);
}
::selection {
background: oklch(63% 0.19 40 / 0.25);
}
a {
color: var(--color-primary-text);
text-decoration: none;
}
a:hover {
color: var(--color-primary-hover);
}
input,
textarea {
font-family: inherit;
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 32px 24px 64px;
}
.page-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.page-title {
font-size: 30px;
margin: 0;
font-weight: 700;
}
.search-bar {
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 10px 16px;
color: var(--color-text-muted);
min-width: 220px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--color-text-muted);
}
.empty-icon {
font-size: 40px;
margin-bottom: 12px;
}
.empty-title {
font-size: 18px;
font-weight: 600;
}
.empty-subtitle {
margin-top: 6px;
}
.page-title--sm {
font-size: 28px;
}
.btn-primary {
cursor: pointer;
display: inline-block;
padding: 11px 18px;
border-radius: 10px;
background: var(--color-primary);
color: #fff;
font-weight: 700;
border: none;
font-family: inherit;
font-size: 15px;
}
.btn-primary:hover {
background: var(--color-primary-hover);
color: #fff;
}
.btn-secondary {
cursor: pointer;
display: inline-block;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-border);
font-weight: 600;
font-size: 14px;
background: transparent;
color: var(--color-text);
font-family: inherit;
}
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
+31
View File
@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}