Add scouthub-attivita-fe
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.angular
|
||||
*.log
|
||||
.git
|
||||
.idea
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+8734
File diff suppressed because it is too large
Load Diff
@@ -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]))
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -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: '' },
|
||||
];
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user