48 Commits

Author SHA1 Message Date
julien 2f82633dd9 refactor(services): migrate user and applications services to auth domain
Update _apiDomain in UserService (/cms → /auth) and ApplicationsService
(/skydive → /auth) following the backend route reorganization.

fix(calculator): use nullish coalescing for currentUser.poids to preserve
default weight when no skydiver profile exists

docs: update README
2026-05-01 20:43:52 +02:00
julien cd0a6c16aa docs: README update 2026-04-27 23:08:41 +02:00
julien 76c97352bc chore(models): minor updates on application and jump model 2026-04-27 21:28:37 +02:00
julien fa00719a3e feat(herowars): migrate JSON static imports to HttpClient assets
Replace all TypeScript JSON imports with an HWDataService that loads
6 data files from /assets/files-data/ via HttpClient with shareReplay(1).
Update all 9 consumer components to subscribe asynchronously. Remove
unused qcm-bpa.json import and dead methods from QcmComponent.
Add ADR 0013 documenting the decision.
2026-04-26 23:46:35 +02:00
julien 86c075db10 chore: remove React/Next.js remnants from Angular codebase
The develop branch had been contaminated by a merge with the old
Next.js/React version of the project. These 66 files (pages, layouts,
_components, _types, server actions, CSS modules) were Next.js App Router
artifacts unused by and invisible to the Angular application.
2026-04-26 17:31:34 +02:00
julien 9ad8129321 chore: remove unused and legacy JSON data files
- qcm/: MongoDB export dumps (dev_acproapidb.users, headupdb.*,
  qcm_categories) — not imported anywhere in the codebase
- dashboard-components/*/data.json: scaffolding stubs — not imported
  in their respective component .ts files
2026-04-26 17:17:52 +02:00
julien c8e2fba13e docs(adr): convert all ADRs to MADR 2.1.2 format
Rewrites all 12 frontend ADRs from a custom structure to the MADR 2.1.2
template required by the VS Code ADR Manager extension: bullet metadata
(Status/Date), standardised section headings, "Chosen option: X, because Y"
wording, and explicit Pros/Cons blocks per option.
2026-04-26 16:50:34 +02:00
julien 8f2632f456 Add 12 ADRs documenting frontend architecture decisions 2026-04-26 16:34:34 +02:00
julien c575992d64 Restore docs/decisions/ for ADRs 2026-04-26 16:20:24 +02:00
julien 7215c01741 Remove one-shot data migration tool (jumps.json + importJumps) 2026-04-26 16:15:54 +02:00
julien dd8fdeb5ca Remove Next.js/React legacy files
The project migrated to Angular 18; Next.js artifacts were still present.

Deleted:
- next.config.js, next-env.d.ts (.next/ was already gitignored)
- public/ (favicons/fonts/images duplicated in src/assets/; not served by Angular)
- .env.development, .env.production, .env.sample (Angular uses src/environments/)
- src/middleware.ts (Next.js middleware)
- src/modules/ (React TSX components — article, auth, profile)
- src/utils/ (Next.js api client, iron-session auth, types)
- src/config/ (process.env-based constants)
- src/generated/ (openapi-typescript generated types)
- src/jumps_02.json (only referenced as a commented-out import)
- docs/ (empty directories)

Updated .gitignore to block .env.* and next.config.js from being re-added.
2026-04-26 16:09:30 +02:00
julien 9be9f191bb Fix Dart Sass deprecation warnings
- Replace color.red/green/blue() with color.channel() in styles.scss
- Replace deprecated if() ternary with @if/@else in _variants.scss
- Silence @import deprecation in angular.json (Bootstrap 5.x uses @import
  throughout; migrating to @use breaks the variable override mechanism)
2026-04-26 07:32:15 +02:00
julien e12bbf29ae fix(build): remove unused Angular imports (NG8113) and chartist CSS 2026-04-26 07:13:57 +02:00
julien bdfef72124 chore(node): update engines field and add .nvmrc for Node 20.19.6 2026-04-26 06:50:34 +02:00
julien 8c898cd652 chore(deps): upgrade Angular 20 → 21
Migrates to Angular 21.0.0: updates tsconfig lib to es2022, migrates
bootstrap options in main.ts, and completes control flow migration
(*ngFor/*ngIf → @for/@if) in jumps and jumps-by-month templates to
resolve duplicate ng-template name conflicts skipped by the schematic.
Also fixes pre-commit hook to load nvm before running tests.
2026-04-26 06:49:50 +02:00
julien be5f5775ef chore(deps): upgrade Angular 19 → 20
- ng update @angular/core@20 @angular/cli@20 @angular/material@20 @angular-eslint@20 @angular/google-maps@20
- Remove ng-chartist (abandoned, incompatible with Angular 20): replace <x-chartist> with <app-bars-chart> in dropzones-bar and jumps-by-month
- Migrate all constructor injection to inject() function (ng generate @angular/core:inject, 67 files)
- TypeScript 5.5 → 5.9.3
- DOCUMENT import moved from @angular/common to @angular/core (automatic migration)
- tsconfig moduleResolution updated to "bundler"
2026-04-26 06:40:13 +02:00
julien 5400294d45 chore(deps): upgrade Angular 18 → 19
- ng update @angular/core@19 @angular/cli@19 @angular/material@19 @angular-eslint/schematics@19
- Migration: remove standalone:true (now default in v19) from 60 components
- Migration: zone.js 0.14 → 0.15
- Fix pre-existing build budget error: raise initial bundle limit to 5mb (app ships large static JSON assets)
2026-04-26 06:25:02 +02:00
julien b8eb8a9393 feat(tooling): add lint-staged with Prettier + ESLint on pre-commit
- Install prettier, lint-staged, eslint-config-prettier
- Configure lint-staged in package.json: Prettier write on staged files, ESLint check on .ts/.html
- Add eslint-config-prettier to .eslintrc.json extends to disable formatting rules that conflict with Prettier
- Configure no-unused-vars to allow _ prefix (intentional destructuring pattern)
- Replace manual prettier/git update-index lines in pre-commit hook with npx lint-staged
- Fix pre-existing ESLint errors: remove unused imports in herowars-guildraid, fix useless escapes in middleware.ts, fix eslint-disable comment in utilityTypes.ts
2026-04-26 06:07:07 +02:00
julien b645f28daf fix(tests): fix all 39 failing specs and re-enable pre-commit hook
- Fix circular dependency in guildwar-attack/defence: import FortificationCardContentComponent directly instead of via @components/shared barrel
- Add index === -1 guard in guildwar-attack, guildwar-defence, guildwar-teams _setChampionsByPower to avoid crash when members input is empty
- Fix MembersStatisticsComponent to guard against undefined member in ngOnInit
- Fix JumpComponent: initialize jump with sautants/author defaults to avoid template crash
- Add provideRouter([]), provideHttpClient(), provideAnimations(), provideNativeDateAdapter() to 26 spec files missing them
- Register Chart.js scales in chart component specs
- Rewrite FrenchPaginator spec to instantiate service directly
- Fix AppComponent spec assertions to match actual component
- Re-enable npm test in .husky/pre-commit
2026-04-26 05:28:58 +02:00
julien 8cdd03b28b fix(styles): restore icosvg- icon classes broken by icomoon merge conflict
Deleted icomoon.scss (icon- prefix, unused) and restored _icomoon.scss
(icosvg- prefix) which is used throughout the app menu and settings.
2026-04-26 04:50:06 +02:00
julien 246d420fe5 chore: merge angular branch into develop
Resolves add/add conflicts by keeping Angular-specific versions.
Fixes pre-existing build issues surfaced by the merge:
- Remove obsolete _icomoon.scss (conflict with icomoon.scss)
- Replace deprecated async with waitForAsync in 6 spec files (Angular 18)
- Add stylePreprocessorOptions.includePaths to test config in angular.json
- Disable pre-commit test hook (pre-existing failures in HeroWars suite)
2026-04-26 04:45:19 +02:00
julien 69cf4548d2 refactor(naming): harmonize service file names and API domain prefixes
- Rename hwclan.service.ts → hw-clan.service.ts, hwmember.service.ts → hw-member.service.ts (Angular Style Guide kebab-case)
- Replace _apiVersion (/v1, /v2) with _apiDomain (/skydive, /cms, /herowars) across all feature services
- Update herowars barrel to reference renamed files
- Update CLAUDE.md to reflect corrected path examples
2026-04-26 00:39:03 +02:00
julien 5a7baf4d94 chore(jumps): remove dead feed type from query method
The /feed branch was never called — all consumers pass type 'all'.
Mirrors removal of the skydive /jumps/feed backend route.
2026-04-26 00:16:35 +02:00
julien c7d21bda30 docs: add CLAUDE.md with project guidance for AI assistants 2026-04-25 17:23:33 +02:00
julien 3ecc83458f chore(angular): add @angular/localize/init polyfill
Required for fr-FR locale support that the app already configures
via registerLocaleData and LOCALE_ID provider in app.config.ts.
2026-04-25 17:23:25 +02:00
julien d6c864d6d1 fix(menu): correct "shydive" typo to "skydive" in admin menu
Eight items under the admin menu had a misspelled parent state,
breaking the parent/child relationship for the skydive submenu.
Also reparents the qcm submenu to skydive (was sitting at the root).
2026-04-25 17:23:03 +02:00
julien 223955eb84 refactor(environment): rename config keys to camelCase
Aligns environment.* shape with TypeScript convention.
Renames: api_url -> apiBaseUrl, api_key -> apiKey,
use_api_key -> useApiKey, refresh_interval -> refreshInterval,
google_map_api_key -> googleMapApiKey.

The local environment.{ts,development.ts,local.ts} files are
gitignored and have already been migrated; this commit updates
the tracked example file and the only consumer (apiInterceptor).
2026-04-25 17:22:49 +02:00
julien c32191b37d Material Upgrade 2026-03-30 02:09:35 +02:00
julien bd8ed94b1d Angular update to v18 2026-03-30 01:52:34 +02:00
julien 518168f225 Angular upgrade to v18 2026-03-30 01:48:45 +02:00
julien f9ee9ed799 Packages update 2026-03-30 01:13:03 +02:00
julien 59361d46a3 Menu link update 2026-03-30 01:05:32 +02:00
julien 9d5d0fca56 Environments update 2026-03-29 23:50:56 +02:00
julien f5e8c765b7 Environments update 2026-03-29 23:44:29 +02:00
julien 8a602bd2db Refactoring 2026-03-29 23:23:33 +02:00
julien 43d61fcf16 Environments Update 2026-03-29 23:20:02 +02:00
julien 86032382d6 Add gitignore for environments 2026-03-29 23:05:25 +02:00
julien 8a1c19e011 Ajout de composants Hero Wars 2025-11-27 10:35:38 +01:00
Rampeur 8e3bd16b60 Ajout d'un pipe 'floor' 2025-09-23 22:30:43 +02:00
Rampeur 5340e737c6 Ajout du composant 'demo' 2025-09-23 22:28:15 +02:00
Rampeur 8b098f7545 Mise à jour graphique 2025-09-23 22:25:08 +02:00
Rampeur 65ae9fce4b Mise à jour de services et resolvers 2025-08-29 16:07:08 +02:00
Rampeur a0a194306f Mise à jour du menu de la sidebar 2025-08-29 16:05:37 +02:00
Rampeur 452475b26e Ajouts et modifications de models 2025-08-29 16:04:20 +02:00
Rampeur ed6104ed21 Mise à jour articles et produits 2025-08-19 05:12:16 +02:00
Rampeur 977104aac2 Mise à jour du layout 2025-08-13 01:28:11 +02:00
Rampeur 0a6cbc0c00 Import des sources angular à partir de 'headup_app' 2025-08-11 23:26:29 +02:00
Rampeur 7dcb426ef5 Suppression de fichiers 2025-08-11 22:57:30 +02:00
669 changed files with 146955 additions and 372264 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"permissions": {
"allow": [
"Bash(grep \" FAILED$\")",
"Bash(grep -A2 \" FAILED$\")",
"Bash(grep -A3 \" FAILED$\")",
"Bash(grep -v \"Executed\\\\|^--$\")",
"Bash(grep -E \"TOTAL:|FAILED$\")",
"Bash(node -e \"const c = require\\('chart.js'\\); console.log\\(Object.keys\\(c\\).filter\\(k => k.includes\\('Doughnut'\\) || k.includes\\('Controller'\\)\\)\\)\")",
"Bash(npm list *)",
"Bash(ng update *)",
"Bash(npm run *)",
"Bash(nvm ls *)",
"Bash(grep -E \"\\\\.\\(js|ts\\)$\")"
]
}
}
+16
View File
@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false
-2
View File
@@ -1,2 +0,0 @@
APP_ENV="development"
API_BASE_URL=http://localhost:3201
-2
View File
@@ -1,2 +0,0 @@
APP_ENV="production"
API_BASE_URL=http://api.adastra-cbd.com/api
-2
View File
@@ -1,2 +0,0 @@
APP_ENV="development"
API_BASE_URL=http://localhost:3200/api
+35 -6
View File
@@ -1,14 +1,43 @@
{ {
"extends": ["next/core-web-vitals", "next/typescript", "prettier"], "root": true,
"ignorePatterns": ["projects/**/*"],
"overrides": [
{
"files": ["*.ts"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates",
"prettier"
],
"rules": { "rules": {
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
"warn", "error",
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
],
"@angular-eslint/directive-selector": [
"error",
{ {
"argsIgnorePattern": "^_", "type": "attribute",
"varsIgnorePattern": "^_", "prefix": "app",
"caughtErrorsIgnorePattern": "^_", "style": "camelCase"
"destructuredArrayIgnorePattern": "^_" }
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
} }
] ]
} }
},
{
"files": ["*.html"],
"extends": ["plugin:@angular-eslint/template/recommended", "plugin:@angular-eslint/template/accessibility"],
"rules": {}
}
]
} }
+12 -31
View File
@@ -6,36 +6,10 @@
/out-tsc /out-tsc
/bazel-out /bazel-out
# dependencies # Node
/node_modules /node_modules
/.pnp npm-debug.log
.pnp.js yarn-error.log
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# production
/build
# testing
/coverage
# next.js
/.next/
/out/
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# IDEs and editors # IDEs and editors
.idea/ .idea/
@@ -59,10 +33,10 @@ next-env.d.ts
.nx/ .nx/
.sass-cache/ .sass-cache/
/connect.lock /connect.lock
/coverage
/libpeerconnection.log /libpeerconnection.log
testem.log testem.log
/typings /typings
*.pem
# e2e # e2e
/e2e/*.js /e2e/*.js
@@ -80,4 +54,11 @@ sonar.properties
**/*.copy.scss **/*.copy.scss
**/*.copy.html **/*.copy.html
certificates # Next.js (legacy, removed)
.next/
next-env.d.ts
next.config.js
# Environment files (Angular uses src/environments/*.ts)
.env
.env.*
+5 -3
View File
@@ -1,3 +1,5 @@
npm test export NVM_DIR="$HOME/.nvm"
#prettier $(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') --write --ignore-unknown [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
#git update-index --again nvm use 20.19.6
npm test -- --watch=false
npx lint-staged
+1
View File
@@ -0,0 +1 @@
20.19.6
+72
View File
@@ -0,0 +1,72 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
- `npm start` — dev server (production config) on port 4200
- `npm run dev` — dev server with `APP_ENV=development` on port 4300, uses `environment.development.ts`
- `npm run local` — dev server with `APP_ENV=local` on port 4400, uses `environment.local.ts`
- `npm run serve` — alias for local config on port 4201
- `npm run build` — production build to `dist/adastra_angular/`
- `npm run watch` — incremental build in development configuration
- `npm run lint` — angular-eslint over `src/**/*.ts` and `src/**/*.html`
- `npm test` — Karma + Jasmine (single spec: `ng test --include src/path/to/file.spec.ts`)
Node engines: `^18.19.1 || ^20.11.1`. Angular 18, TypeScript 5.5, strict mode with `strictTemplates`.
## Environments
`src/environments/environment.ts` is the default (production). `environment.development.ts` and `environment.local.ts` are swapped in via `fileReplacements` in `angular.json` for their respective configurations. `environment.example.ts` documents the expected shape (`apiBaseUrl`, `apiKey`, `useApiKey`, `refreshInterval`, `googleMapApiKey`). Never commit real keys to the development/local files — the production `environment.ts` currently has real keys checked in; be mindful.
## Architecture
Standalone Angular 18 app (no NgModules) wired up in [src/app/app.config.ts](src/app/app.config.ts). It provides the router with `withViewTransitions()`, three HTTP interceptors, charts, French locale (`fr-FR`), and an `APP_INITIALIZER` that eagerly fetches the current user when a JWT is present in localStorage.
### Routing split
Routes are composed in [src/app/app.routes.ts](src/app/app.routes.ts) from two arrays:
- [src/app/routes/auth.routes.ts](src/app/routes/auth.routes.ts) — `authGuard`-protected routes. Skydive admin sections (`aeronefs`, `canopies`, `dashboard`, `dropzones`, `jumps`) additionally require `adminGuard`.
- [src/app/routes/noauth.routes.ts](src/app/routes/noauth.routes.ts) — public pages and auth-entry routes (`login`, `register`) gated by `noauthGuard` (blocks entry when already authenticated).
Resolvers under [src/app/core/resolvers/](src/app/core/resolvers/) pre-fetch data for each route — new route components should generally follow this pattern rather than fetching in `ngOnInit`.
### HTTP pipeline
Three interceptors chained in order ([src/app/core/interceptors/](src/app/core/interceptors/)):
1. `apiInterceptor` — prepends `environment.apiBaseUrl` to every request URL. **Consequence:** services call relative paths like `/cms/user`, never absolute URLs.
2. `tokenInterceptor` — attaches `Authorization: Token <jwt>` when a token exists.
3. `errorInterceptor` — unwraps `err.error` and rethrows.
`ApiService` ([api.service.ts](src/app/core/services/api.service.ts)) is a thin `HttpClient` wrapper; most feature services call it with a versioned prefix (e.g., `_apiDomain = '/skydive'` or `/cms`). The API version is per-service, not global.
### Auth flow
`JwtService` stores the token in `localStorage['jwtToken']`. `UserService` exposes `currentUser` (BehaviorSubject) and `isAuthenticated` (ReplaySubject) observables consumed by guards and the layout. The `APP_INITIALIZER` in `app.config.ts` calls `userService.getCurrentUser()` if a token exists, so guards can rely on `isAuthenticated` being populated on first navigation. Admin role check is `currentUser.role === 'Admin'`.
### Layout
[AppComponent](src/app/app.component.ts) renders only `FullComponent` ([src/app/components/shared/layout/full.component.ts](src/app/components/shared/layout/full.component.ts)), which owns the Material sidenav + toolbar shell and swaps menu items between `MENUITEMS` and `MENUITEMSADMIN` defined in [src/app/components/shared/menu-items.ts](src/app/components/shared/menu-items.ts) based on role. Route changes render inside its `<router-outlet>`.
### Feature areas
The app spans four mostly-independent feature domains, mirroring the backend's route grouping:
- **E-commerce** — `products`, `product/:slug`, `products/:category` (public + auth variants). Categories are baked into menu items, not dynamic. Backend counterpart: `/api/ecommerce/*`.
- **CMS** — `pages`, `page/:slug`, `home`, profiles, articles. Backend counterpart: `/api/cms/*`.
- **Skydive** — `skydive/*` routes, services in [src/app/core/services/skydive/](src/app/core/services/skydive/), QCM exam data in `src/app/components/qcm/*.json` and `src/qcm-bpa.json`. Backend counterpart: `/api/skydive/*`.
- **Hero Wars** — analytics for a game guild, services in [src/app/core/services/herowars/](src/app/core/services/herowars/), weekly data snapshots in [src/files-data/](src/files-data/) (`Week_XX` folders and `hw-*.json` files are static data shipped as assets). Backend counterpart: `/api/herowars/*`.
### Path aliases
Configured in [tsconfig.json](tsconfig.json) and used everywhere — prefer them over relative paths:
`@components`, `@services`, `@models`, `@guards`, `@interceptors`, `@resolvers`, `@viewmodels`, `@interfaces`, `@constants`, `@core/*`, `@environments/*`, `@data/*` (→ `src/files-data/*`), `@assets/*`, `@styles/*`.
Each alias points at an `index.ts` barrel — when adding a new component/service/model, also export it from the appropriate barrel or it won't be importable via the alias.
### Styling
SCSS with `includePaths: ["src/styles"]` so imports like `@use 'variables'` resolve. Global Chartist styles are registered in `angular.json` build options. Material 18 with French date locale and `DD/MM/YYYY` format configured in `AppComponent`'s providers.
## Related service
The backend API lives in a sibling repo at `/Users/julien/Sites/adastra_api` (Express + Sequelize, additional working directory). Routes are grouped under `src/routes/api/` into the same four domains as the frontend: `skydive`, `cms`, `ecommerce`, `herowars`. The `v1`, `v2`, `v3` folders alongside them are legacy backups slated for removal — ignore them. When a task spans both repos (e.g., adding an endpoint + consumer), check there as well.
+88 -33
View File
@@ -1,42 +1,97 @@
# AdAstra_App # AdAstra App
## Libraries and Frameworks Frontend of the AdAstra platform — a multi-domain Angular application covering e-commerce, CMS, skydive club management, and Hero Wars guild analytics.
| Name | | ## Tech stack
| --------------------------------------------- | ---------------------------------- |
| [TypeScript](https://www.typescriptlang.org/) | v5.5.x |
| [Next.js](https://nextjs.org/) | v15.x |
| [React](https://react.dev/) | v18.x |
| [Conform](https://conform.guide/) | form validation library |
| [Zod](https://zod.dev/) | schema validation library |
| [unified](https://unifiedjs.com/) | markdown to html converter |
| [OpenAPI TypeScript](https://openapi-ts.dev/) | type generator from OpenAPI schema |
## Getting Started | Layer | Choice |
| --------- | ------------------------------------------------------------------ |
| Framework | Angular 21 (standalone components, no NgModules) |
| Language | TypeScript 5.9 — strict mode + strictTemplates |
| UI | Angular Material 21, Bootstrap 5 |
| Charts | Chart.js 4 (ng2-charts), Chartist |
| HTTP | Angular HttpClient with three interceptors (base URL, JWT, errors) |
| Tests | Karma + Jasmine |
| Linting | angular-eslint + Prettier (enforced via lint-staged) |
| Node | `^20.19.0 \|\| ^22.12.0` |
## Related repository
The backend API lives in [`adastra_api`](../adastra_api) — an Express + Sequelize stack, routes grouped under `/api/ecommerce`, `/api/cms`, `/api/skydive`, `/api/herowars`.
## Getting started
```bash ```bash
# copy env npm install
cp .env.example .env.local npm run local # dev server on :4400, uses environment.local.ts
# set API_BASE_URL variable in .env.local to your backend api endpoint
# run app
npm run dev
``` ```
## Directory structure Copy and configure your local environment:
```plaintext ```bash
. cp src/environments/environment.example.ts src/environments/environment.local.ts
├── api/ # api schema # fill in: apiBaseUrl, apiKey, googleMapApiKey, …
├── public/ # static assets
└── src/
├── app/ # web routes
├── config/ # global configuration and constants
├── generated/ # automatically generated codes
├── modules/
│ ├── common/ # common (feature-independent) components
│ └── features/ # feature-specific components
├── styles/ # global style sheets
└── utils/ # utilities
``` ```
## Commands
| Command | Description |
| --------------- | ----------------------------------------------------- |
| `npm start` | Dev server on :4200 with production config |
| `npm run dev` | Dev server on :4300 with `environment.development.ts` |
| `npm run local` | Dev server on :4400 with `environment.local.ts` |
| `npm run build` | Production build → `dist/adastra_angular/` |
| `npm run watch` | Incremental build in development configuration |
| `npm run lint` | angular-eslint on `**/*.ts` and `**/*.html` |
| `npm test` | Karma + Jasmine unit tests |
Run a single spec:
```bash
ng test --include src/path/to/file.spec.ts
```
## Architecture
### Bootstrap
`AppComponent` renders a single `FullComponent` shell (Material sidenav + toolbar). Routes are split into two arrays in `app.routes.ts`:
- `auth.routes.ts` — protected by `authGuard`; admin sections additionally require `adminGuard`
- `noauth.routes.ts` — public pages and auth entry points, blocked by `noauthGuard` when already authenticated
Data is pre-fetched by resolvers under `src/app/core/resolvers/` — new route components should follow this pattern rather than fetching in `ngOnInit`.
### HTTP pipeline
Three interceptors applied in order:
1. **`apiInterceptor`** — prepends `environment.apiBaseUrl` to every request. Services use relative paths (`/cms/user`, `/skydive/jumps`, …), never absolute URLs.
2. **`tokenInterceptor`** — attaches `Authorization: Token <jwt>` when a token exists in `localStorage['jwtToken']`.
3. **`errorInterceptor`** — unwraps `err.error` and rethrows.
### Feature domains
| Domain | Routes | Backend prefix |
| ---------- | ------------------------------------------------------ | ---------------- |
| E-commerce | `products`, `product/:slug`, `products/:category` | `/api/ecommerce` |
| CMS | `pages`, `page/:slug`, `home`, profiles, articles | `/api/cms` |
| Skydive | `skydive/*` — aeronefs, canopies, dropzones, jumps | `/api/skydive` |
| Hero Wars | Guild analytics, weekly snapshots in `src/files-data/` | `/api/herowars` |
### Path aliases
Defined in `tsconfig.json`; always prefer them over relative imports:
```
@components @services @models @guards
@interceptors @resolvers @viewmodels @interfaces
@constants @core/* @environments/*
@data/* @assets/* @styles/*
```
When adding a new file, export it from the appropriate `index.ts` barrel so the alias resolves correctly.
### Styling
SCSS with `includePaths: ["src/styles"]` — imports like `@use 'variables'` resolve globally. Material date locale is set to `fr-FR` with `DD/MM/YYYY` format.
+184
View File
@@ -0,0 +1,184 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false,
"schematicCollections": ["@angular-eslint/schematics"]
},
"newProjectRoot": "projects",
"projects": {
"adastra_angular": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"i18n": {
"sourceLocale": "fr"
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": {
"base": "dist/adastra_angular"
},
"index": "src/index.html",
"tsConfig": "tsconfig.app.json",
"polyfills": ["@angular/localize/init", "zone.js"],
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest",
{
"glob": "_redirects",
"input": "src",
"output": "/"
}
],
"styles": ["src/styles/styles.scss"],
"stylePreprocessorOptions": {
"includePaths": ["src/styles"],
"sass": {
"silenceDeprecations": ["import"]
}
},
"scripts": [],
"browser": "src/main.ts"
},
"configurations": {
"production": {
"extractLicenses": true,
"namedChunks": false,
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"budgets": [
{
"type": "initial",
"maximumWarning": "3mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
]
},
"development": {
"extractLicenses": false,
"namedChunks": true,
"optimization": false,
"sourceMap": true,
"budgets": [
{
"type": "anyComponentStyle",
"maximumWarning": "6kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
},
"local": {
"extractLicenses": false,
"namedChunks": true,
"optimization": false,
"sourceMap": true,
"budgets": [
{
"type": "anyComponentStyle",
"maximumWarning": "6kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.local.ts"
}
]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "adastra_angular:build:production"
},
"development": {
"buildTarget": "adastra_angular:build:development"
},
"local": {
"buildTarget": "adastra_angular:build:local"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "adastra_angular:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets", "src/manifest.webmanifest"],
"styles": ["src/styles/styles.scss"],
"scripts": [],
"stylePreprocessorOptions": {
"includePaths": ["src/styles"]
}
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}
@@ -0,0 +1,47 @@
# Migrate frontend from Next.js to Angular
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
The project was initially built with Next.js (React). The development team already maintained an existing Angular stack for other projects, creating a knowledge split between two frontend frameworks. Server-side rendering — Next.js's main differentiator — was not required: all data is user-specific and served via authenticated API calls.
## Considered Options
- Keep Next.js/React
- Migrate to Angular
- Migrate to Vue or Svelte
## Decision Outcome
Chosen option: "Migrate to Angular", because the team already maintained an Angular stack for other projects, making it the path of least resistance with no additional knowledge split.
### Positive Consequences
- Single frontend framework across projects — reuse of existing expertise, libraries, and patterns.
- No SSR complexity; the app is a pure SPA, which fits Angular's model naturally.
### Negative Consequences
- Complete rewrite — no incremental migration path exists between React and Angular.
- Next.js artifacts (`.next/`, `src/modules/`, `src/middleware.ts`, etc.) required a dedicated cleanup pass after the migration.
## Pros and Cons of the Options
### Keep Next.js/React
- Good, because no rewrite cost.
- Bad, because maintaining two frontend frameworks in parallel increases overhead.
- Bad, because SSR provides no benefit for this authenticated, user-specific application.
### Migrate to Angular
- Good, because leverages existing team expertise.
- Good, because consistent toolchain across projects.
- Bad, because full rewrite required.
### Migrate to Vue or Svelte
- Good, because smaller bundle size.
- Bad, because no existing team expertise — introduces a third framework.
@@ -0,0 +1,42 @@
# Use Angular standalone components without NgModules
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
Angular historically required all components, directives, and pipes to be declared inside NgModules. Angular 14 introduced standalone components as an opt-in. Angular 17+ made them the recommended default and they are required for the esbuild-based application builder. How should the application be structured?
## Considered Options
- Standalone components (no NgModules)
- NgModule-based architecture
## Decision Outcome
Chosen option: "Standalone components (no NgModules)", because they are the Angular-recommended default since v17, provide explicit per-component dependency declarations, and are required for the esbuild application builder.
### Positive Consequences
- Each component explicitly declares its own `imports` — dependencies are visible and not hidden in a shared module.
- Better tree-shaking: unused imports in one component don't affect others.
- Aligns with Angular's long-term direction.
### Negative Consequences
- Slightly more verbose component decorators — each component lists its own imports rather than inheriting from a module.
## Pros and Cons of the Options
### Standalone components (no NgModules)
- Good, because explicit dependency declarations per component.
- Good, because required for the esbuild application builder.
- Good, because Angular-recommended default since v17.
- Bad, because more verbose decorators.
### NgModule-based architecture
- Good, because familiar pattern for developers coming from Angular < 14.
- Bad, because deprecated direction; incompatible with the modern application builder.
- Bad, because shared module patterns hide dependencies.
@@ -0,0 +1,56 @@
# Coexist Angular Material and Bootstrap during UI library consolidation
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
The application currently uses two UI libraries: Angular Material (interactive components) and Bootstrap 5 (layout, spacing utilities). Bootstrap was the original styling layer; Angular Material was introduced incrementally. Maintaining both increases bundle size, creates styling inconsistencies, and adds cognitive overhead. Which strategy should be adopted?
## Considered Options
- Keep both libraries and consolidate later
- Consolidate to Angular Material only
- Consolidate to Bootstrap only
- Migrate to a different library (PrimeNG, Spartan/shadcn-angular, etc.)
## Decision Outcome
Chosen option: "Keep both libraries and consolidate later", because a big-bang UI rewrite is not justified at this stage and the final target library has not yet been chosen.
### Positive Consequences
- No immediate rewrite required — development velocity is preserved.
- Each library covers the other's gaps during the transition period.
### Negative Consequences
- Two styling systems in tension, leading to inconsistent UX.
- Bootstrap's `@import`-based Sass is incompatible with the `@use` module system, requiring a deprecation workaround (see ADR 0012).
- Larger CSS bundle than a single-library solution.
## Pros and Cons of the Options
### Keep both libraries and consolidate later
- Good, because no rewrite cost now.
- Bad, because styling inconsistency and larger bundle persist until consolidation.
### Consolidate to Angular Material only
- Good, because single coherent design system.
- Bad, because Bootstrap layout utilities would need to be replaced.
### Consolidate to Bootstrap only
- Good, because removes Angular Material dependency.
- Bad, because Angular Material components (dialogs, date pickers, etc.) would need rebuilding.
### Migrate to a different library
- Good, because could address limitations of both current libraries.
- Bad, because highest rewrite cost; unknown tradeoffs until evaluated.
## Links
- Related to [ADR 0012](0012-scss-bootstrap-import-deprecation.md)
@@ -0,0 +1,46 @@
# Handle API base URL, auth token, and errors via HTTP interceptors
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
Every API call requires three cross-cutting concerns: prepending the API base URL, attaching the JWT bearer token, and unwrapping error responses into a consistent shape. How should these be applied across all HTTP calls without duplicating logic in each service?
## Considered Options
- Three chained HTTP interceptors
- Per-service base URL and token handling
- Single utility wrapper function
## Decision Outcome
Chosen option: "Three chained HTTP interceptors", because they centralise cross-cutting concerns transparently and require no changes to individual services.
### Positive Consequences
- Services use relative paths (e.g. `/skydive/jumps`) — environment-agnostic.
- Auth header injection is transparent to all services.
- Uniform error payload shape across the entire application.
### Negative Consequences
- Services must always use relative paths starting with `/`. Passing an absolute URL would cause the base URL to be prepended twice.
## Pros and Cons of the Options
### Three chained HTTP interceptors
- Good, because zero duplication — logic defined once, applied everywhere.
- Good, because services remain unaware of environment or auth details.
- Bad, because interceptors apply globally — opting out requires explicit handling.
### Per-service base URL and token handling
- Good, because each service controls its own behaviour explicitly.
- Bad, because duplicates the same logic across every service.
### Single utility wrapper function
- Good, because more explicit than interceptors.
- Bad, because all service calls must go through the wrapper — easy to bypass accidentally.
@@ -0,0 +1,48 @@
# Store JWT authentication token in localStorage
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
The application requires persistent authentication across browser sessions. Where should the JWT token be stored client-side?
## Decision Drivers
- Application is internal — used by a known, controlled user base; not publicly accessible.
- Implementation simplicity.
- Compatibility with Angular functional guards and the APP_INITIALIZER auth bootstrap.
## Considered Options
- Store JWT in localStorage
- Store JWT in an httpOnly cookie
## Decision Outcome
Chosen option: "Store JWT in localStorage", because the application is internal with a controlled user base, making the XSS risk acceptable, and localStorage avoids the additional CSRF complexity of cookie-based auth.
### Positive Consequences
- Simple implementation — no server-side session management.
- Token persists across browser restarts without requiring re-login.
- Works seamlessly with Angular functional guards and `APP_INITIALIZER`.
### Negative Consequences
- Accessible to JavaScript — an XSS attack could exfiltrate the token.
- If the application ever becomes publicly accessible or handles sensitive data, this decision must be revisited.
## Pros and Cons of the Options
### Store JWT in localStorage
- Good, because simple to implement and operate.
- Good, because survives browser restarts.
- Bad, because XSS-vulnerable.
### Store JWT in an httpOnly cookie
- Good, because inaccessible to JavaScript — XSS-safe.
- Bad, because requires CSRF protection (SameSite cookie policy or CSRF tokens).
- Bad, because more complex server-side coordination.
@@ -0,0 +1,42 @@
# Pre-fetch route data using Angular route resolvers
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
Route components need data to render. Should that data be fetched before the route activates (resolver) or after the component renders (in-component)?
## Considered Options
- Angular route resolvers
- In-component fetching (ngOnInit)
## Decision Outcome
Chosen option: "Angular route resolvers", because components receive data immediately via `ActivatedRoute.data`, eliminating the need for per-component loading states on initial render.
### Positive Consequences
- No loading state needed in components for the initial data fetch.
- Loading indication can be centralised at the router level via router events.
- Data availability is guaranteed before any component lifecycle hook runs.
### Negative Consequences
- Navigation appears paused while the resolver fetches — there is no partial render before data arrives.
- Resolver errors must be handled explicitly to avoid navigation hangs on API failure.
## Pros and Cons of the Options
### Angular route resolvers
- Good, because data is available immediately in the component.
- Good, because loading logic is centralised outside the component.
- Bad, because navigation is blocked until the resolver completes.
### In-component fetching (ngOnInit)
- Good, because navigation is instant — component renders immediately.
- Bad, because each component must manage its own loading and error states.
- Bad, because inconsistent UX across routes if not implemented uniformly.
@@ -0,0 +1,43 @@
# Use the esbuild-based Angular application builder
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
Angular 17 introduced a new application builder (`@angular/build:application`) based on esbuild and Vite, replacing the legacy webpack-based `@angular-devkit/build-angular:browser` builder. Which builder should be used for development and production builds?
## Considered Options
- `@angular/build:application` — esbuild-based (Angular 17+)
- `@angular-devkit/build-angular:browser` — webpack-based (legacy)
## Decision Outcome
Chosen option: "`@angular/build:application`", because it is the Angular-recommended builder since v17, delivers significantly faster builds, and is required for certain schema-level features such as `stylePreprocessorOptions.sass.silenceDeprecations`.
### Positive Consequences
- Build times are 35× faster than the webpack builder for development and production.
- Native ESM output with better code splitting.
- Aligns with Angular's recommended toolchain for v17+.
### Negative Consequences
- Webpack-specific configuration options (custom plugins, loaders) no longer apply. Not an issue for this project.
- The `sass` sub-option under `stylePreprocessorOptions` is specific to this builder's schema; the Karma test builder uses a stricter schema and does not support it.
## Pros and Cons of the Options
### `@angular/build:application` (esbuild)
- Good, because significantly faster cold builds and incremental rebuilds.
- Good, because Angular-recommended default since v17.
- Good, because required for `sass.silenceDeprecations` support.
- Bad, because webpack plugin ecosystem is not compatible.
### `@angular-devkit/build-angular:browser` (webpack)
- Good, because supports custom webpack configuration.
- Bad, because deprecated direction; slower builds.
- Bad, because does not support the `sass.silenceDeprecations` option.
@@ -0,0 +1,51 @@
# Keep Karma + Jasmine as the test stack
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
Angular's default test setup uses Karma as the test runner (executing tests in a real Chrome browser) and Jasmine as the spec framework. Faster alternatives exist. Should the test stack be replaced?
## Considered Options
- Karma + Jasmine (Angular default)
- Jest (Node/jsdom, no real browser)
- Vitest (ESM-native, no real browser)
## Decision Outcome
Chosen option: "Karma + Jasmine", because the test suite (54 specs) is small enough that Karma's startup overhead is not a bottleneck, and migration would require replacing test utilities and adjusting Angular `TestBed` setup with no functional gain at the current scale.
### Positive Consequences
- Zero migration effort — full compatibility with Angular's `TestBed` and `ComponentFixture` APIs.
- Tests run in a real Chrome browser, catching browser-specific issues that jsdom misses.
### Negative Consequences
- Slower startup than Jest or Vitest due to Chrome launch. Acceptable at the current test suite size.
## Pros and Cons of the Options
### Karma + Jasmine
- Good, because zero migration cost from the Angular default.
- Good, because tests run in a real browser environment.
- Bad, because slower startup than headless alternatives.
### Jest
- Good, because fast — runs in Node via jsdom with no browser launch.
- Bad, because requires `jest-preset-angular`, removal of `zone.js/testing`, and test utility changes.
- Bad, because jsdom is not a real browser — some browser-specific behaviours are not caught.
### Vitest
- Good, because ESM-native, fastest option.
- Bad, because Angular `TestBed` integration requires additional configuration.
- Bad, because jsdom limitation applies here too.
## Links
- Future consideration: if the test suite grows significantly or CI performance becomes a concern, evaluate migration to Jest with `jest-preset-angular`.
@@ -0,0 +1,60 @@
# Enforce quality gates at commit time with Husky and lint-staged
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
Without automated enforcement, code quality rules (linting, formatting, tests) are discovered late — in CI, or not at all. Should quality checks be enforced at commit time?
## Decision Drivers
- Issues should be caught before they enter the repository, not after.
- Formatting should be applied automatically so developers don't need to run it manually.
- The correct Node version must be active during hook execution regardless of the shell environment.
## Considered Options
- Husky + lint-staged pre-commit hook
- CI-only enforcement (no local hooks)
- No automated enforcement
## Decision Outcome
Chosen option: "Husky + lint-staged pre-commit hook", because it prevents lint errors and failing tests from entering the repository, and auto-fixes formatting on commit without developer intervention.
The hook runs in order:
1. Full test suite (`npm test -- --watch=false`) — blocks commit on any failing spec.
2. `lint-staged` on staged files: `prettier --write` on `*.{ts,html,scss,json,md}`, then `eslint --max-warnings=0` on `*.ts` and `*.html`.
nvm is initialised inside the hook script to ensure the correct Node version (pinned in `.nvmrc`) is active regardless of the shell environment that triggered the commit.
### Positive Consequences
- No lint errors or failing tests can enter the repository via a normal commit.
- Prettier auto-fixes formatting on commit — developers don't need to run it manually.
### Negative Consequences
- Commits are slower (~1020 s for the full test suite). Acceptable trade-off for a solo project.
- Requires nvm to be installed on the developer's machine. The hook sources `$HOME/.nvm/nvm.sh` directly.
## Pros and Cons of the Options
### Husky + lint-staged
- Good, because issues are caught before they enter the repository.
- Good, because formatting is applied automatically.
- Bad, because adds latency to every commit.
- Bad, because requires nvm on the developer's machine.
### CI-only enforcement
- Good, because no local overhead.
- Bad, because broken commits are discovered only after push.
### No automated enforcement
- Good, because zero overhead.
- Bad, because quality degrades without consistent manual discipline.
@@ -0,0 +1,51 @@
# Pin Node version with nvm and .nvmrc
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
Angular 18 requires Node.js `^18.19.1 || ^20.11.1`. The system Node version on a development machine may not meet this requirement, causing silent build failures or CLI errors. How should the required Node version be enforced?
## Considered Options
- nvm with `.nvmrc`
- System Node (no version management)
- Other version managers (fnm, volta)
## Decision Outcome
Chosen option: "nvm with `.nvmrc`", because it pins the version explicitly in the repository, is already in use on the development machine, and integrates directly with the Husky pre-commit hook.
A `.nvmrc` file at the repository root pins Node to `20.19.6`. The Husky pre-commit hook explicitly calls `nvm use 20.19.6` before running tests to ensure the correct version is active in the hook's shell environment.
### Positive Consequences
- Consistent Node version across all commands: dev server, build, tests, pre-commit hook.
- `.nvmrc` documents the required Node version explicitly in the repository.
### Negative Consequences
- Requires nvm. Developers using other version managers (fnm, volta) must align manually.
## Pros and Cons of the Options
### nvm with `.nvmrc`
- Good, because explicit version pinning visible in the repository.
- Good, because integrates with Husky hook via `nvm use`.
- Bad, because tied to nvm — other version managers need manual alignment.
### System Node
- Good, because no tooling required.
- Bad, because version is not pinned — any system upgrade can silently break the build.
### Other version managers (fnm, volta)
- Good, because faster and more ergonomic than nvm for some workflows.
- Bad, because not the currently installed tool — switching would require migration.
## Links
- The `package.json` `engines` field (`^18.19.1 || ^20.11.1`) provides a secondary signal for tooling that reads it.
@@ -0,0 +1,46 @@
# Organise code by functional domain
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
The application covers four independent functional areas with distinct data models, API backends, and user audiences: Skydive, CMS, E-commerce, and Hero Wars. Without explicit organisational boundaries, code from different domains would intermingle, making each area harder to reason about independently. How should the codebase be structured?
## Considered Options
- Domain-based organisation (one folder per functional area)
- Flat feature-based organisation (one folder per component type)
## Decision Outcome
Chosen option: "Domain-based organisation", because it mirrors the backend's route grouping, making the full-stack data flow traceable, and allows each domain to be understood and modified independently.
Services are placed under `src/app/core/services/<domain>/`, API prefixes are `/skydive`, `/cms`, `/ecommerce`, `/herowars`, and routes are composed from `auth.routes.ts` and `noauth.routes.ts` with domain-specific guards.
### Positive Consequences
- Each domain can be understood and modified independently.
- Consistent mapping between frontend service paths and backend API routes simplifies debugging.
### Negative Consequences
- Cross-domain features (shared auth, user profile) must be placed in `core/` to avoid circular dependencies.
## Pros and Cons of the Options
### Domain-based organisation
- Good, because domain boundaries are explicit and enforced by directory structure.
- Good, because mirrors the backend route grouping — frontend `core/services/skydive/` maps to backend `src/routes/api/skydive/`.
- Bad, because shared cross-domain concerns need a neutral `core/` layer.
### Flat feature-based organisation
- Good, because all components of the same type are co-located.
- Bad, because domain boundaries are invisible — mixing domains is too easy.
## Links
- Hero Wars uses static JSON files (`src/files-data/`) rather than live API calls — weekly snapshots updated manually. This is intentional and not a gap in the domain structure.
- Related to backend [ADR 0004](../../adastra_api/docs/decisions/0004-route-organisation-by-domain.md)
@@ -0,0 +1,57 @@
# Silence the Sass @import deprecation for the Bootstrap import chain
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
Bootstrap 5 relies on Sass's `@import` rule and `!default` variable overrides: our custom `_variable.scss` must be loaded before Bootstrap's `variables.scss` so that Bootstrap picks up our values via the `!default` mechanism. Dart Sass 1.80+ deprecates `@import` in favour of the `@use` module system. How should the deprecation be addressed?
## Decision Drivers
- Bootstrap 5's `!default` override mechanism is incompatible with `@use` module scopes — variables loaded via `@use` do not flow into Bootstrap's module scope.
- Other Dart Sass deprecations (`color.red/green/blue()`, global `if()`) were fixed properly.
## Considered Options
- Silence the `@import` deprecation via `angular.json`
- Migrate to `@use` with `@use "bootstrap" with (...)`
- Wait for Bootstrap 6
## Decision Outcome
Chosen option: "Silence the `@import` deprecation via `angular.json`", because the `@use` migration is blocked by Bootstrap 5's architecture and Bootstrap 6 (which adopts `@use` natively) is not yet released.
The deprecation is suppressed in `angular.json` via `stylePreprocessorOptions.sass.silenceDeprecations: ["import"]`. The existing `@import`-based Bootstrap loading chain is kept intact. All other Dart Sass deprecations are fixed properly.
### Positive Consequences
- Bootstrap customisation works correctly with no behaviour change.
- No risk of introducing regressions by attempting an incompatible migration now.
### Negative Consequences
- Deprecation is silenced rather than fixed. `@import` will be removed in Dart Sass 3.0.
## Pros and Cons of the Options
### Silence the `@import` deprecation
- Good, because no behaviour change — existing variable overrides continue to work.
- Bad, because the underlying issue is deferred, not resolved.
### Migrate to `@use` with `@use "bootstrap" with (...)`
- Good, because fully resolves the deprecation.
- Bad, because requires rewriting all Bootstrap variable overrides using `with ()` syntax — significant effort.
- Bad, because the `with ()` syntax has limitations compared to `!default` chaining.
### Wait for Bootstrap 6
- Good, because Bootstrap 6 adopts `@use` natively — migration path will be documented by the Bootstrap team.
- Bad, because Bootstrap 6 release date is unknown; deferring means living with the silenced warning.
## Links
- Related to [ADR 0003](0003-dual-ui-library-progressive-migration.md)
- Future action: when Bootstrap 6 is released with `@use` support, migrate the import chain, remove the `silenceDeprecations` entry, and supersede this ADR.
@@ -0,0 +1,67 @@
# Migrate HeroWars static JSON imports to HttpClient assets
- Status: accepted
- Date: 2026-04-26
## Context and Problem Statement
Six large JSON files (36 KB3 MB) were statically imported directly into Angular components and services via TypeScript `import` statements. This bundled all the data into the main JavaScript bundle, inflating the initial download regardless of whether the user navigated to the HeroWars section. The largest file (`hw-guild-raids.json`, 3 MB) alone would significantly delay first contentful paint for all users.
Additionally, a seventh file (`qcm-bpa.json`, 210 KB) was imported but never consumed — the component had switched to a route resolver but the dead import remained.
## Decision Drivers
- Static JSON imports are compiled into the bundle — they cannot be lazy-loaded or cached by the browser independently.
- Updating the data files required a recompile and redeployment; serving them as assets allows hot-swapping without a rebuild.
- The HeroWars section is used by a small subset of users; its data should not penalise all other users' load time.
## Considered Options
- Keep static imports
- Move JSON to assets and load via `HttpClient`
- Fetch data from the API backend
## Decision Outcome
Chosen option: "Move JSON to assets and load via HttpClient", because it removes the data from the bundle, enables browser-level HTTP caching, and requires no backend changes. The files are not secret (guild-internal analytics data) so serving them as static assets is appropriate.
Structure:
- JSON files moved from `src/files-data/` to `src/assets/files-data/`
- `HWDataService` created at `src/app/core/services/herowars/hw-data.service.ts` — exposes one `readonly` observable per file, each piped through `shareReplay(1)` so the HTTP request fires at most once per app lifecycle
- `HWClanService.loadClan()` and `HWMemberService.loadMembers()` converted from synchronous return values to `Observable<T>`
- All consumer components inject `HWDataService` and subscribe in `ngOnInit()`; template-called methods that previously accessed `guildWarSlots.slots` inline now read from a `_slots` class property populated in the subscribe callback
- Unused `import data from 'src/qcm-bpa.json'` and two dead import-helper methods removed from `qcm.component.ts`
### Positive Consequences
- HeroWars JSON (~4 MB total) no longer shipped in the initial bundle.
- Each JSON file is independently cacheable by the browser with standard HTTP cache headers.
- Data files can be updated without recompiling the Angular application.
- `shareReplay(1)` ensures a single HTTP request per session even when multiple components subscribe to the same observable.
### Negative Consequences
- Components now initialize asynchronously — there is a brief render before data arrives (consistent with the rest of the app's HTTP-driven components).
- `Object.entries()` on `any`-typed HTTP responses requires explicit `Record<string, T>` casts to satisfy strict TypeScript, adding minor verbosity.
## Pros and Cons of the Options
### Keep static imports
- Good, because synchronous — no async lifecycle complexity.
- Bad, because adds up to 4 MB to the initial bundle.
- Bad, because data updates require a full rebuild and redeployment.
### Move JSON to assets and load via HttpClient
- Good, because removes data from the bundle entirely.
- Good, because browser-cacheable independently of the app JS.
- Good, because hot-swappable without a recompile.
- Bad, because converts synchronous service methods to observables, requiring component updates.
### Fetch from API backend
- Good, because data could be managed dynamically via the admin interface.
- Bad, because requires significant backend work (new endpoints, data model) for data that is currently managed as flat files.
- Bad, because adds latency and a failure mode not present with local assets.
-6
View File
@@ -1,6 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig
+14375 -4514
View File
File diff suppressed because it is too large Load Diff
+71 -40
View File
@@ -1,49 +1,80 @@
{ {
"name": "adastra-nextjs", "name": "adastra-app",
"version": "0.1.0", "version": "1.0.0",
"private": true, "author": "Solide Apps <admin@unespace.com>",
"stackblitz": { "contributors": [
"startCommand": "npm start" "Julien Gautier <julien.gautier@unespace.com>"
}, ],
"scripts": { "scripts": {
"dev": "next dev --port 4201", "ng": "ng",
"secure": "next dev --experimental-https", "start": "ng serve",
"build": "next build", "build": "ng build",
"start": "next start", "watch": "ng build --watch --configuration development",
"lint": "next lint", "serve": "ng serve --configuration local --port 4201",
"prepare": "husky", "dev": "APP_ENV=development ng serve --port 4300 --configuration development",
"format": "prettier . --write", "local": "APP_ENV=local ng serve --port 4400 --configuration local",
"fix": "next lint --fix", "lint": "ng lint",
"test": "npm run build", "test": "ng test"
"openapi-generate": "openapi-typescript ./api/openapi.yml --output ./src/generated/apiSchema.generated.d.ts"
}, },
"engines": {
"node": "^20.19.0 || ^22.12.0"
},
"private": true,
"dependencies": { "dependencies": {
"@conform-to/react": "^1.2.2", "@angular/animations": "^21.2.10",
"@conform-to/zod": "^1.2.2", "@angular/cdk": "^21.2.8",
"clsx": "^2.1.1", "@angular/common": "^21.2.10",
"@angular/compiler": "^21.2.10",
"@angular/core": "^21.2.10",
"@angular/forms": "^21.2.10",
"@angular/google-maps": "^21.2.8",
"@angular/localize": "^21.2.10",
"@angular/material": "^21.2.8",
"@angular/platform-browser": "^21.2.10",
"@angular/platform-browser-dynamic": "^21.2.10",
"@angular/router": "^21.2.10",
"bootstrap": "^5.3.7", "bootstrap": "^5.3.7",
"next": "^15.4.5", "chart.js": "^4.4.2",
"react": "^19.1.1", "chartist": "^1.3.0",
"react-bootstrap": "^2.10.10", "ng2-charts": "^6.0.1",
"react-dom": "^19.1.1", "ngx-skeleton-loader": "^9.0.0",
"rehype-stringify": "^10.0.1", "rxjs": "~7.8.0",
"remark-parse": "^11.0.0", "tslib": "^2.3.0",
"remark-rehype": "^11.1.1", "zone.js": "^0.15.1"
"unified": "^11.0.5",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.1.0", "@angular-devkit/build-angular": "^21.2.8",
"@types/react": "^19.1.9", "@angular-eslint/builder": "21.3.1",
"@types/react-dom": "^19.1.7", "@angular-eslint/eslint-plugin": "21.3.1",
"eslint": "^9.32.0", "@angular-eslint/eslint-plugin-template": "21.3.1",
"eslint-config-next": "^15.4.5", "@angular-eslint/schematics": "21.3.1",
"eslint-config-prettier": "^9.1.0", "@angular-eslint/template-parser": "21.3.1",
"husky": "^9.1.6", "@angular/cli": "^21.2.8",
"lint-staged": "^15.2.10", "@angular/compiler-cli": "^21.2.10",
"openapi-typescript": "^7.4.1", "@types/jasmine": "~5.1.0",
"prettier": "^3.3.3", "@typescript-eslint/eslint-plugin": "7.2.0",
"typescript": "^5.9.2", "@typescript-eslint/parser": "7.2.0",
"sass": "^1.89.2" "eslint": "^8.57.0",
"eslint-config-prettier": "^10.1.8",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"lint-staged": "^16.4.0",
"prettier": "^3.8.3",
"typescript": "~5.9.3"
},
"lint-staged": {
"*.{ts,html,scss,json,md}": [
"prettier --write"
],
"*.ts": [
"eslint --max-warnings=0"
],
"*.html": [
"eslint --max-warnings=0"
]
} }
} }
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.
-19
View File
@@ -1,19 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Generated by IcoMoon</metadata>
<defs>
<font id="icomoon" horiz-adv-x="1024">
<font-face units-per-em="1024" ascent="960" descent="-64" />
<missing-glyph horiz-adv-x="1024" />
<glyph unicode="&#x20;" horiz-adv-x="512" d="" />
<glyph unicode="&#xe900;" glyph-name="home" d="M1024 369.556l-512 397.426-512-397.428v162.038l512 397.426 512-397.428zM896 384v-384h-256v256h-256v-256h-256v384l384 288z" />
<glyph unicode="&#xe905;" glyph-name="pencil" d="M864 960c88.364 0 160-71.634 160-160 0-36.020-11.91-69.258-32-96l-64-64-224 224 64 64c26.742 20.090 59.978 32 96 32zM64 224l-64-288 288 64 592 592-224 224-592-592zM715.578 596.422l-448-448-55.156 55.156 448 448 55.156-55.156z" />
<glyph unicode="&#xe93a;" glyph-name="cart" d="M384 32c0-53.019-42.981-96-96-96s-96 42.981-96 96c0 53.019 42.981 96 96 96s96-42.981 96-96zM1024 32c0-53.019-42.981-96-96-96s-96 42.981-96 96c0 53.019 42.981 96 96 96s96-42.981 96-96zM1024 448v384h-768c0 35.346-28.654 64-64 64h-192v-64h128l48.074-412.054c-29.294-23.458-48.074-59.5-48.074-99.946 0-70.696 57.308-128 128-128h768v64h-768c-35.346 0-64 28.654-64 64 0 0.218 0.014 0.436 0.016 0.656l831.984 127.344z" />
<glyph unicode="&#xe942;" glyph-name="phone" d="M704 320c-64-64-64-128-128-128s-128 64-192 128-128 128-128 192 64 64 128 128-128 256-192 256-192-192-192-192c0-128 131.5-387.5 256-512s384-256 512-256c0 0 192 128 192 192s-192 256-256 192z" />
<glyph unicode="&#xe945;" glyph-name="envelop" d="M928 832h-832c-52.8 0-96-43.2-96-96v-640c0-52.8 43.2-96 96-96h832c52.8 0 96 43.2 96 96v640c0 52.8-43.2 96-96 96zM398.74 409.628l-270.74-210.892v501.642l270.74-290.75zM176.38 704h671.24l-335.62-252-335.62 252zM409.288 398.302l102.712-110.302 102.71 110.302 210.554-270.302h-626.528l210.552 270.302zM625.26 409.628l270.74 290.75v-501.642l-270.74 210.892z" />
<glyph unicode="&#xe953;" glyph-name="calendar" d="M320 576h128v-128h-128zM512 576h128v-128h-128zM704 576h128v-128h-128zM128 192h128v-128h-128zM320 192h128v-128h-128zM512 192h128v-128h-128zM320 384h128v-128h-128zM512 384h128v-128h-128zM704 384h128v-128h-128zM128 384h128v-128h-128zM832 960v-64h-128v64h-448v-64h-128v64h-128v-1024h960v1024h-128zM896 0h-832v704h832v-704z" />
<glyph unicode="&#xe971;" glyph-name="user" d="M576 253.388v52.78c70.498 39.728 128 138.772 128 237.832 0 159.058 0 288-192 288s-192-128.942-192-288c0-99.060 57.502-198.104 128-237.832v-52.78c-217.102-17.748-384-124.42-384-253.388h896c0 128.968-166.898 235.64-384 253.388z" />
<glyph unicode="&#xe9ca;" glyph-name="earth" d="M512 960c-282.77 0-512-229.23-512-512s229.23-512 512-512 512 229.23 512 512-229.23 512-512 512zM512-0.002c-62.958 0-122.872 13.012-177.23 36.452l233.148 262.29c5.206 5.858 8.082 13.422 8.082 21.26v96c0 17.674-14.326 32-32 32-112.99 0-232.204 117.462-233.374 118.626-6 6.002-14.14 9.374-22.626 9.374h-128c-17.672 0-32-14.328-32-32v-192c0-12.122 6.848-23.202 17.69-28.622l110.31-55.156v-187.886c-116.052 80.956-192 215.432-192 367.664 0 68.714 15.49 133.806 43.138 192h116.862c8.488 0 16.626 3.372 22.628 9.372l128 128c6 6.002 9.372 14.14 9.372 22.628v77.412c40.562 12.074 83.518 18.588 128 18.588 70.406 0 137.004-16.26 196.282-45.2-4.144-3.502-8.176-7.164-12.046-11.036-36.266-36.264-56.236-84.478-56.236-135.764s19.97-99.5 56.236-135.764c36.434-36.432 85.218-56.264 135.634-56.26 3.166 0 6.342 0.080 9.518 0.236 13.814-51.802 38.752-186.656-8.404-372.334-0.444-1.744-0.696-3.488-0.842-5.224-81.324-83.080-194.7-134.656-320.142-134.656z" />
<glyph unicode="&#xe9cb;" glyph-name="link" d="M440.236 324.234c-13.31 0-26.616 5.076-36.77 15.23-95.134 95.136-95.134 249.934 0 345.070l192 192c46.088 46.086 107.36 71.466 172.534 71.466s126.448-25.38 172.536-71.464c95.132-95.136 95.132-249.934 0-345.070l-87.766-87.766c-20.308-20.308-53.23-20.308-73.54 0-20.306 20.306-20.306 53.232 0 73.54l87.766 87.766c54.584 54.586 54.584 143.404 0 197.99-26.442 26.442-61.6 41.004-98.996 41.004s-72.552-14.562-98.996-41.006l-192-191.998c-54.586-54.586-54.586-143.406 0-197.992 20.308-20.306 20.306-53.232 0-73.54-10.15-10.152-23.462-15.23-36.768-15.23zM256-52c-65.176 0-126.45 25.38-172.534 71.464-95.134 95.136-95.134 249.934 0 345.070l87.764 87.764c20.308 20.306 53.234 20.306 73.54 0 20.308-20.306 20.308-53.232 0-73.54l-87.764-87.764c-54.586-54.586-54.586-143.406 0-197.992 26.44-26.44 61.598-41.002 98.994-41.002s72.552 14.562 98.998 41.006l192 191.998c54.584 54.586 54.584 143.406 0 197.992-20.308 20.308-20.306 53.232 0 73.54 20.306 20.306 53.232 20.306 73.54-0.002 95.132-95.134 95.132-249.932 0.002-345.068l-192.002-192c-46.090-46.088-107.364-71.466-172.538-71.466z" />
</font></defs></svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

-55
View File
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Logo_Ad_Astra" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" width="218px" height="48px" viewBox="0 0 218 48" enable-background="new 0 0 218 48" xml:space="preserve">
<g id="Ad_Astra_3_">
<g>
<polygon fill="#D7C067" points="66.158,26.352 64.543,20.319 54.508,20.319 52.896,26.352 "/>
<polygon fill="#D7C067" points="62.985,38.615 63.716,41.5 70.209,41.5 66.724,28.614 60.308,28.614 61.658,33.665 57.394,33.665
58.745,28.614 52.25,28.614 48.842,41.5 55.299,41.5 56.069,38.615 "/>
</g>
<g>
<path fill="#D7C067" d="M92.467,26.352v-1.212c0-1.325-0.508-2.472-1.51-3.409c-1.003-0.937-2.23-1.412-3.647-1.412H71.733v6.033
H92.467z"/>
<path fill="#D7C067" d="M92.467,28.614h-6.456v6.853H78.19v-6.853h-6.457V41.5H87.31c1.418,0,2.645-0.475,3.647-1.411
c1.002-0.938,1.51-2.083,1.51-3.409V28.614z"/>
</g>
<g>
<polygon fill="#D7C067" points="119.495,26.352 117.88,20.319 107.846,20.319 106.232,26.352 "/>
<polygon fill="#D7C067" points="116.322,38.615 117.053,41.5 123.546,41.5 120.061,28.614 113.644,28.614 114.995,33.665
110.73,33.665 112.081,28.614 105.588,28.614 102.179,41.5 108.635,41.5 109.406,38.615 "/>
</g>
<g>
<path fill="#D7C067" d="M145.803,33.434c0-1.324-0.508-2.471-1.509-3.408c-1.002-0.937-2.229-1.412-3.647-1.412h-15.571
c0.056,1.229,0.561,2.297,1.505,3.179c1.001,0.936,2.229,1.41,3.647,1.41h9.121v2.264H125.07V41.5h15.576
c1.418,0,2.645-0.475,3.647-1.411c1.001-0.938,1.509-2.083,1.509-3.409V33.434z"/>
<path fill="#D7C067" d="M125.07,26.352h20.732v-6.033h-15.575c-1.417,0-2.646,0.475-3.647,1.412
c-1.002,0.936-1.51,2.084-1.51,3.409V26.352z"/>
</g>
<g>
<rect x="155.362" y="28.614" fill="#D7C067" width="6.456" height="12.886"/>
<rect x="148.223" y="20.319" fill="#D7C067" width="20.733" height="6.033"/>
</g>
<g>
<path fill="#D7C067" d="M192.109,26.352v-1.212c0-1.325-0.508-2.472-1.51-3.409c-1.003-0.937-2.23-1.412-3.647-1.412h-15.576
v6.033H192.109z"/>
<path fill="#D7C067" d="M171.375,41.5h6.456v-8.296h7.823V41.5h6.456v-8.066c0-0.679-0.481-1.473-1.432-2.362l-0.174-0.163
l0.174-0.163c0.846-0.79,1.319-1.506,1.414-2.132h-20.716V41.5z"/>
</g>
<g>
<polygon fill="#D7C067" points="207.776,38.615 208.508,41.5 215,41.5 211.515,28.614 205.099,28.614 206.45,33.665
202.185,33.665 203.536,28.614 197.041,28.614 193.632,41.5 200.088,41.5 200.861,38.615 "/>
<polygon fill="#D7C067" points="210.948,26.352 209.335,20.319 199.3,20.319 197.686,26.352 "/>
</g>
</g>
<g id="Star_1_">
<path fill-rule="evenodd" clip-rule="evenodd" fill="#DDDCDB" d="M67.011,15.14c-15.125-0.415-30.13,11.212-42.833,19.917
l5.321-16.303l2.163,5.883c0.43,1.075,0.917,0.809,2.133,0.276C45.682,19.162,51.53,13.062,67.011,15.14z"/>
<path fill="#D7C067" d="M21.111,39.24l8.584-6.51l9.078,9.012l-8.772-5.538L16.491,46l4.396-17.216L3,19.504l21.795,7.159
L21.111,39.24z"/>
<path fill="#D7C067" d="M25.738,23.656L7.23,18.529l16.482,1.101l2.655-8.707l2.032,5.081L25.738,23.656z"/>
<path fill="#D7C067" d="M28.784,3l6.61,15.685c0.112,0.257,0.211,0.435,0.442,0.326c9.763-4.918,17.872-12.196,41.689-4.196
c-25.071-5.188-30.394,2.068-43.894,8.159c-0.269,0.111-0.497-0.09-0.729-0.662L27.238,8.07L28.784,3z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

-55
View File
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Logo_Ad_Astra" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" width="272px" height="60px" viewBox="0 0 272 60" enable-background="new 0 0 272 60" xml:space="preserve">
<g id="Ad_Astra_3_">
<g>
<polygon fill="#D7C067" points="82.246,32.325 80.22,24.75 67.628,24.75 65.604,32.325 "/>
<polygon fill="#D7C067" points="78.265,47.726 79.182,51.349 87.329,51.349 82.955,35.167 74.905,35.167 76.599,41.509
71.25,41.509 72.944,35.166 64.795,35.167 60.519,51.349 68.62,51.349 69.587,47.726 "/>
</g>
<g>
<path fill="#D7C067" d="M115.256,32.325v-1.523c0-1.663-0.637-3.104-1.895-4.28c-1.258-1.176-2.797-1.773-4.575-1.773H89.241
v7.576H115.256z"/>
<path fill="#D7C067" d="M115.256,35.167h-8.1v8.606h-9.814v-8.606h-8.101v16.182h19.544c1.779,0,3.318-0.596,4.575-1.772
c1.258-1.177,1.895-2.617,1.895-4.28V35.167z"/>
</g>
<g>
<polygon fill="#D7C067" points="149.168,32.325 147.142,24.75 134.552,24.75 132.527,32.325 "/>
<polygon fill="#D7C067" points="145.187,47.726 146.104,51.349 154.25,51.349 149.878,35.167 141.827,35.167 143.522,41.509
138.171,41.509 139.866,35.166 131.718,35.167 127.441,51.349 135.542,51.349 136.51,47.726 "/>
</g>
<g>
<path fill="#D7C067" d="M182.177,41.219c0-1.662-0.637-3.103-1.894-4.279c-1.257-1.177-2.797-1.773-4.576-1.773h-19.537
c0.07,1.543,0.704,2.885,1.888,3.992c1.257,1.175,2.797,1.771,4.576,1.771h11.444v2.843h-17.915v7.576h19.543
c1.779,0,3.319-0.596,4.576-1.772c1.256-1.177,1.894-2.617,1.894-4.28V41.219z"/>
<path fill="#D7C067" d="M156.164,32.325h26.013V24.75h-19.542c-1.778,0-3.319,0.597-4.576,1.773
c-1.257,1.175-1.895,2.617-1.895,4.28V32.325z"/>
</g>
<g>
<rect x="194.171" y="35.167" fill="#D7C067" width="8.101" height="16.182"/>
<rect x="185.214" y="24.75" fill="#D7C067" width="26.015" height="7.576"/>
</g>
<g>
<path fill="#D7C067" d="M240.278,32.325v-1.523c0-1.663-0.637-3.104-1.895-4.28c-1.259-1.176-2.798-1.773-4.576-1.773h-19.544
v7.576H240.278z"/>
<path fill="#D7C067" d="M214.264,51.349h8.1V40.93h9.815v10.418h8.1V41.219c0-0.852-0.604-1.85-1.796-2.966l-0.219-0.204
l0.219-0.205c1.061-0.992,1.655-1.891,1.774-2.677h-25.992V51.349z"/>
</g>
<g>
<polygon fill="#D7C067" points="259.936,47.726 260.854,51.349 269,51.349 264.627,35.167 256.577,35.167 258.272,41.509
252.92,41.509 254.616,35.166 246.466,35.167 242.189,51.349 250.291,51.349 251.26,47.726 "/>
<polygon fill="#D7C067" points="263.916,32.325 261.893,24.75 249.301,24.75 247.275,32.325 "/>
</g>
</g>
<g id="Star_1_">
<path fill-rule="evenodd" clip-rule="evenodd" fill="#DDDCDB" d="M83.315,18.245c-18.977-0.521-37.805,14.08-53.743,25.012
l6.676-20.473l2.715,7.387c0.54,1.35,1.15,1.016,2.676,0.347C56.554,23.297,63.892,15.637,83.315,18.245z"/>
<path fill="#D7C067" d="M25.725,48.51l10.771-8.175l11.39,11.317l-11.007-6.955L19.927,57l5.516-21.621L3,23.726l27.347,8.99
L25.725,48.51z"/>
<path fill="#D7C067" d="M31.53,28.94L8.308,22.501l20.681,1.383l3.331-10.934l2.55,6.38L31.53,28.94z"/>
<path fill="#D7C067" d="M35.352,3l8.294,19.697c0.14,0.322,0.265,0.545,0.554,0.408C56.45,16.93,66.625,7.79,96.508,17.836
c-31.457-6.516-38.135,2.598-55.074,10.246c-0.337,0.14-0.624-0.113-0.914-0.832L33.412,9.368L35.352,3z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

-41
View File
@@ -1,41 +0,0 @@
{
"name": "App",
"icons": [
{
"src": "\/favicon\/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "\/favicon\/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "\/favicon\/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "\/favicon\/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "\/favicon\/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "\/favicon\/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}
-1
View File
@@ -1 +0,0 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+1
View File
@@ -0,0 +1 @@
/* /index.html 200
@@ -1,34 +0,0 @@
import { Pagination, PaginationItem } from "@/modules/common/components/pagination";
import { calcTotalPageNumber } from "@/modules/common/functions/pagination";
import { ArticleCard } from "@/modules/features/article/components/articleCard";
import { fetchFeedArticles } from "@/modules/features/article/fetch/fetchArticleList";
const Pages = ({ totalPages, currentPage }: { totalPages: number; currentPage: number }) => (
<Pagination>
{[...Array(totalPages)].map((_, index) => {
const page = index + 1;
const href = `/?tab=global&page=${page}`;
return (
<PaginationItem href={href} active={page === currentPage} key={index}>
{page}
</PaginationItem>
);
})}
</Pagination>
);
export const FeedArticleList = async ({ currentPage }: { currentPage: number }) => {
const { articles, articlesCount } = await fetchFeedArticles(currentPage);
const totalPages = calcTotalPageNumber(articlesCount, 10);
return articles.length < 1 ? (
<p>No articles found.</p>
) : (
<>
{articles.map((article, index) => (
<ArticleCard key={index} article={article} />
))}
{1 < totalPages && <Pages currentPage={currentPage} totalPages={totalPages} />}
</>
);
};
@@ -1 +0,0 @@
export * from "./feedArticleList";
@@ -1,34 +0,0 @@
import { Pagination, PaginationItem } from "@/modules/common/components/pagination";
import { calcTotalPageNumber } from "@/modules/common/functions/pagination";
import { ArticleCard } from "@/modules/features/article/components/articleCard";
import { fetchGlobalArticles } from "@/modules/features/article/fetch/fetchArticleList";
const Pages = ({ totalPages, currentPage }: { totalPages: number; currentPage: number }) => (
<Pagination>
{[...Array(totalPages)].map((_, index) => {
const page = index + 1;
const href = `/?tab=global&page=${page}`;
return (
<PaginationItem href={href} active={page === currentPage} key={index}>
{page}
</PaginationItem>
);
})}
</Pagination>
);
export const GlobalArticleList = async ({ currentPage }: { currentPage: number }) => {
const { articles, articlesCount } = await fetchGlobalArticles(currentPage);
const totalPages = calcTotalPageNumber(articlesCount, 10);
return articles.length < 1 ? (
<p>No articles found.</p>
) : (
<>
{articles.map((article, index) => (
<ArticleCard key={index} article={article} />
))}
{1 < totalPages && <Pages currentPage={currentPage} totalPages={totalPages} />}
</>
);
};
@@ -1 +0,0 @@
export * from "./globalArticleList";
@@ -1,2 +0,0 @@
export * from "./logoHeader";
export * from "./logoSmall";
@@ -1,44 +0,0 @@
const LogoHeader: React.FC = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="272" height="60" viewBox="0 0 272 60">
<g id="Ad_Astra_3_">
<g>
<polygon fill="#D7C067" points="82.246,32.325 80.22,24.75 67.628,24.75 65.604,32.325 " />
<polygon fill="#D7C067" points="78.265,47.726 79.182,51.349 87.329,51.349 82.955,35.167 74.905,35.167 76.599,41.509 71.25,41.509 72.944,35.166 64.795,35.167 60.519,51.349 68.62,51.349 69.587,47.726 " />
</g>
<g>
<path fill="#D7C067" d="M115.256,32.325v-1.523c0-1.663-0.637-3.104-1.895-4.28c-1.258-1.176-2.797-1.773-4.575-1.773H89.241 v7.576H115.256z" />
<path fill="#D7C067" d="M115.256,35.167h-8.1v8.606h-9.814v-8.606h-8.101v16.182h19.544c1.779,0,3.318-0.596,4.575-1.772 c1.258-1.177,1.895-2.617,1.895-4.28V35.167z" />
</g>
<g>
<polygon fill="#D7C067" points="149.168,32.325 147.142,24.75 134.552,24.75 132.527,32.325 " />
<polygon fill="#D7C067" points="145.187,47.726 146.104,51.349 154.25,51.349 149.878,35.167 141.827,35.167 143.522,41.509 138.171,41.509 139.866,35.166 131.718,35.167 127.441,51.349 135.542,51.349 136.51,47.726 " />
</g>
<g>
<path fill="#D7C067" d="M182.177,41.219c0-1.662-0.637-3.103-1.894-4.279c-1.257-1.177-2.797-1.773-4.576-1.773h-19.537 c0.07,1.543,0.704,2.885,1.888,3.992c1.257,1.175,2.797,1.771,4.576,1.771h11.444v2.843h-17.915v7.576h19.543 c1.779,0,3.319-0.596,4.576-1.772c1.256-1.177,1.894-2.617,1.894-4.28V41.219z" />
<path fill="#D7C067" d="M156.164,32.325h26.013V24.75h-19.542c-1.778,0-3.319,0.597-4.576,1.773 c-1.257,1.175-1.895,2.617-1.895,4.28V32.325z" />
</g>
<g>
<rect x="194.171" y="35.167" fill="#D7C067" width="8.101" height="16.182" />
<rect x="185.214" y="24.75" fill="#D7C067" width="26.015" height="7.576" />
</g>
<g>
<path fill="#D7C067" d="M240.278,32.325v-1.523c0-1.663-0.637-3.104-1.895-4.28c-1.259-1.176-2.798-1.773-4.576-1.773h-19.544 v7.576H240.278z" />
<path fill="#D7C067" d="M214.264,51.349h8.1V40.93h9.815v10.418h8.1V41.219c0-0.852-0.604-1.85-1.796-2.966l-0.219-0.204 l0.219-0.205c1.061-0.992,1.655-1.891,1.774-2.677h-25.992V51.349z" />
</g>
<g>
<polygon fill="#D7C067" points="259.936,47.726 260.854,51.349 269,51.349 264.627,35.167 256.577,35.167 258.272,41.509 252.92,41.509 254.616,35.166 246.466,35.167 242.189,51.349 250.291,51.349 251.26,47.726 " />
<polygon fill="#D7C067" points="263.916,32.325 261.893,24.75 249.301,24.75 247.275,32.325 " />
</g>
</g>
<g id="Star_1_">
<path fillRule="evenodd" clipRule="evenodd" fill="#DDDCDB" d="M83.315,18.245c-18.977-0.521-37.805,14.08-53.743,25.012 l6.676-20.473l2.715,7.387c0.54,1.35,1.15,1.016,2.676,0.347C56.554,23.297,63.892,15.637,83.315,18.245z" />
<path fill="#D7C067" d="M25.725,48.51l10.771-8.175l11.39,11.317l-11.007-6.955L19.927,57l5.516-21.621L3,23.726l27.347,8.99 L25.725,48.51z" />
<path fill="#D7C067" d="M31.53,28.94L8.308,22.501l20.681,1.383l3.331-10.934l2.55,6.38L31.53,28.94z" />
<path fill="#D7C067" d="M35.352,3l8.294,19.697c0.14,0.322,0.265,0.545,0.554,0.408C56.45,16.93,66.625,7.79,96.508,17.836 c-31.457-6.516-38.135,2.598-55.074,10.246c-0.337,0.14-0.624-0.113-0.914-0.832L33.412,9.368L35.352,3z" />
</g>
</svg>
);
};
export default LogoHeader;
@@ -1,44 +0,0 @@
const LogoSmall: React.FC = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="218" height="48" viewBox="0 0 218 48">
<g id="Ad_Astra_3_">
<g>
<polygon fill="#D7C067" points="66.158,26.352 64.543,20.319 54.508,20.319 52.896,26.352 " />
<polygon fill="#D7C067" points="62.985,38.615 63.716,41.5 70.209,41.5 66.724,28.614 60.308,28.614 61.658,33.665 57.394,33.665 58.745,28.614 52.25,28.614 48.842,41.5 55.299,41.5 56.069,38.615 " />
</g>
<g>
<path fill="#D7C067" d="M92.467,26.352v-1.212c0-1.325-0.508-2.472-1.51-3.409c-1.003-0.937-2.23-1.412-3.647-1.412H71.733v6.033 H92.467z" />
<path fill="#D7C067" d="M92.467,28.614h-6.456v6.853H78.19v-6.853h-6.457V41.5H87.31c1.418,0,2.645-0.475,3.647-1.411 c1.002-0.938,1.51-2.083,1.51-3.409V28.614z" />
</g>
<g>
<polygon fill="#D7C067" points="119.495,26.352 117.88,20.319 107.846,20.319 106.232,26.352 " />
<polygon fill="#D7C067" points="116.322,38.615 117.053,41.5 123.546,41.5 120.061,28.614 113.644,28.614 114.995,33.665 110.73,33.665 112.081,28.614 105.588,28.614 102.179,41.5 108.635,41.5 109.406,38.615 " />
</g>
<g>
<path fill="#D7C067" d="M145.803,33.434c0-1.324-0.508-2.471-1.509-3.408c-1.002-0.937-2.229-1.412-3.647-1.412h-15.571 c0.056,1.229,0.561,2.297,1.505,3.179c1.001,0.936,2.229,1.41,3.647,1.41h9.121v2.264H125.07V41.5h15.576 c1.418,0,2.645-0.475,3.647-1.411c1.001-0.938,1.509-2.083,1.509-3.409V33.434z" />
<path fill="#D7C067" d="M125.07,26.352h20.732v-6.033h-15.575c-1.417,0-2.646,0.475-3.647,1.412 c-1.002,0.936-1.51,2.084-1.51,3.409V26.352z" />
</g>
<g>
<rect x="155.362" y="28.614" fill="#D7C067" width="6.456" height="12.886" />
<rect x="148.223" y="20.319" fill="#D7C067" width="20.733" height="6.033" />
</g>
<g>
<path fill="#D7C067" d="M192.109,26.352v-1.212c0-1.325-0.508-2.472-1.51-3.409c-1.003-0.937-2.23-1.412-3.647-1.412h-15.576 v6.033H192.109z" />
<path fill="#D7C067" d="M171.375,41.5h6.456v-8.296h7.823V41.5h6.456v-8.066c0-0.679-0.481-1.473-1.432-2.362l-0.174-0.163 l0.174-0.163c0.846-0.79,1.319-1.506,1.414-2.132h-20.716V41.5z" />
</g>
<g>
<polygon fill="#D7C067" points="207.776,38.615 208.508,41.5 215,41.5 211.515,28.614 205.099,28.614 206.45,33.665 202.185,33.665 203.536,28.614 197.041,28.614 193.632,41.5 200.088,41.5 200.861,38.615 " />
<polygon fill="#D7C067" points="210.948,26.352 209.335,20.319 199.3,20.319 197.686,26.352 " />
</g>
</g>
<g id="Star_1_">
<path fillRule="evenodd" clipRule="evenodd" fill="#DDDCDB" d="M67.011,15.14c-15.125-0.415-30.13,11.212-42.833,19.917 l5.321-16.303l2.163,5.883c0.43,1.075,0.917,0.809,2.133,0.276C45.682,19.162,51.53,13.062,67.011,15.14z" />
<path fill="#D7C067" d="M21.111,39.24l8.584-6.51l9.078,9.012l-8.772-5.538L16.491,46l4.396-17.216L3,19.504l21.795,7.159 L21.111,39.24z" />
<path fill="#D7C067" d="M25.738,23.656L7.23,18.529l16.482,1.101l2.655-8.707l2.032,5.081L25.738,23.656z" />
<path fill="#D7C067" d="M28.784,3l6.61,15.685c0.112,0.257,0.211,0.435,0.442,0.326c9.763-4.918,17.872-12.196,41.689-4.196 c-25.071-5.188-30.394,2.068-43.894,8.159c-0.269,0.111-0.497-0.09-0.729-0.662L27.238,8.07L28.784,3z" />
</g>
</svg>
);
};
export default LogoSmall;
@@ -1 +0,0 @@
export * from "./tagArticleList";
@@ -1,34 +0,0 @@
import { Pagination, PaginationItem } from "@/modules/common/components/pagination";
import { calcTotalPageNumber } from "@/modules/common/functions/pagination";
import { ArticleCard } from "@/modules/features/article/components/articleCard";
import { fetchArticlesByTag } from "@/modules/features/article/fetch/fetchArticleList";
const Pages = ({ totalPages, currentPage }: { totalPages: number; currentPage: number }) => (
<Pagination>
{[...Array(totalPages)].map((_, index) => {
const page = index + 1;
const href = `/?tab=global&page=${page}`;
return (
<PaginationItem href={href} active={page === currentPage} key={index}>
{page}
</PaginationItem>
);
})}
</Pagination>
);
export const TagArticleList = async ({ tag, currentPage }: { tag: string; currentPage: number }) => {
const { articles, articlesCount } = await fetchArticlesByTag(tag, currentPage);
const totalPages = calcTotalPageNumber(articlesCount, 10);
return articles.length < 1 ? (
<p>No articles found.</p>
) : (
<>
{articles.map((article, index) => (
<ArticleCard key={index} article={article} />
))}
{1 < totalPages && <Pages currentPage={currentPage} totalPages={totalPages} />}
</>
);
};
@@ -1,7 +0,0 @@
import { fetchTagsList } from "@/modules/features/article/fetch/fetchTagsList";
import { TagList as TagListPresentation } from "./presentation";
export const TagList = async () => {
const tags = await fetchTagsList();
return <TagListPresentation tags={tags} />;
};
-1
View File
@@ -1 +0,0 @@
export * from "./container";
@@ -1,16 +0,0 @@
import { Tag } from "@/modules/features/article/components/tag";
type Props = { tags: string[] };
export const TagList = ({ tags }: Props) => (
<div className="sidebar">
<p>Popular Tags</p>
<div className="tag-list">
{tags.map((tag, index) => (
<Tag component="a" variant="filled" href={`/?tab=tag&tag=${tag}`} key={index}>
{tag}
</Tag>
))}
</div>
</div>
);
-1
View File
@@ -1 +0,0 @@
export * from "./timeClock";
-184
View File
@@ -1,184 +0,0 @@
"use client";
import React, { useEffect } from "react";
const TimeClock: React.FC = () => {
useEffect(() => {
const numbers = [
[1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1], // 0
[1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1], // 1
[1, 0, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 0, 1], // 2
[1, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1], // 3
[1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1], // 4
[1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1], // 5
[1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1], // 6
[1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0], // 7
[1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1], // 8
[1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1], // 9
];
const blocks: Array<Element[]> = [];
const digits = Array.from(document.querySelectorAll(".block"));
for (let i = 0; i < 4; i++) {
blocks.push(digits.slice(i * 15, i * 15 + 15));
}
const setNum = (block: Array<Element>, num: number) => {
const n = numbers[num];
for (let i = 0; i < block.length; i++) {
block[i].classList[n[i] === 1 ? "add" : "remove"]("active");
}
};
const time = {
s: "",
m: "",
h: "",
p: 0,
};
// time loop
const animator = () => {
const d = new Date();
let h = d.getHours().toString(),
m = d.getMinutes().toString(),
s = d.getSeconds().toString();
s = s.length === 1 ? "0" + s : s;
m = m.length === 1 ? "0" + m : m;
h = h.length === 1 ? "0" + h : h;
if (s !== time.s) {
for (let i = 0; i < digits.length; i++) {
const d = digits[i];
if (i === +s) {
d.classList.add("second");
if (time.p !== null) digits[time.p].classList.remove("second");
time.p = i;
time.s = s;
}
}
}
if (m !== time.m) {
setNum(blocks[2], parseInt(Array.from(m)[0]));
setNum(blocks[3], parseInt(Array.from(m)[1]));
time.m = m;
}
if (h !== time.h) {
setNum(blocks[0], parseInt(Array.from(h)[0]));
setNum(blocks[1], parseInt(Array.from(h)[1]));
time.h = h;
}
window.requestAnimationFrame(animator);
};
// init
window.requestAnimationFrame(animator);
// toggle button
const d = new Date();
const days = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
const months = [
"Janvier",
"Février",
"Mars",
"Avril",
"Mai",
"Juin",
"Juillet",
"Août",
"Septembre",
"Octobre",
"Novembre",
"Décembre",
];
const lib_d = days[d.getDay()];
const lib_m = months[d.getMonth()];
const lib_n = d.getDate();
const lib_y = d.getFullYear();
document.getElementById("datelib")!.innerHTML = lib_d + " " + lib_n + " " + lib_m + " " + lib_y;
}, []);
return (
<>
<div className="date d-flex mb-2 pt-2">
<div className="flex-grow-1"></div>
<div className="datelib mt-2 pt-1 ps-1 font-monospace fs-5">
<span className="icon-calendar me-2" aria-hidden="true"></span>
<span id="datelib"></span>
</div>
</div>
<div className="time d-flex">
<div className="flex-grow-1"></div>
<div className="clock position-relative d-flex flex-column flex-wrap">
<div className="block" data-num="0"></div>
<div className="block" data-num="1"></div>
<div className="block" data-num="2"></div>
<div className="block" data-num="3"></div>
<div className="block" data-num="4"></div>
<div className="block" data-num="5"></div>
<div className="block" data-num="6"></div>
<div className="block" data-num="7"></div>
<div className="block" data-num="8"></div>
<div className="block" data-num="9"></div>
<div className="block" data-num="10"></div>
<div className="block" data-num="11"></div>
<div className="block" data-num="12"></div>
<div className="block" data-num="13"></div>
<div className="block" data-num="14"></div>
<div className="block" data-num="15"></div>
<div className="block" data-num="16"></div>
<div className="block" data-num="17"></div>
<div className="block" data-num="18"></div>
<div className="block" data-num="19"></div>
<div className="block" data-num="20"></div>
<div className="block" data-num="21"></div>
<div className="block" data-num="22"></div>
<div className="block" data-num="23"></div>
<div className="block" data-num="24"></div>
<div className="block" data-num="25"></div>
<div className="block" data-num="26"></div>
<div className="block" data-num="27"></div>
<div className="block" data-num="28"></div>
<div className="block" data-num="29"></div>
<div className="block" data-num="30"></div>
<div className="block" data-num="31"></div>
<div className="block" data-num="32"></div>
<div className="block" data-num="33"></div>
<div className="block" data-num="34"></div>
<div className="block" data-num="35"></div>
<div className="block" data-num="36"></div>
<div className="block" data-num="37"></div>
<div className="block" data-num="38"></div>
<div className="block" data-num="39"></div>
<div className="block" data-num="40"></div>
<div className="block" data-num="41"></div>
<div className="block" data-num="42"></div>
<div className="block" data-num="43"></div>
<div className="block" data-num="44"></div>
<div className="block" data-num="45"></div>
<div className="block" data-num="46"></div>
<div className="block" data-num="47"></div>
<div className="block" data-num="48"></div>
<div className="block" data-num="49"></div>
<div className="block" data-num="50"></div>
<div className="block" data-num="51"></div>
<div className="block" data-num="52"></div>
<div className="block" data-num="53"></div>
<div className="block" data-num="54"></div>
<div className="block" data-num="55"></div>
<div className="block" data-num="56"></div>
<div className="block" data-num="57"></div>
<div className="block" data-num="58"></div>
<div className="block" data-num="59"></div>
<div className="divider position-absolute"></div>
</div>
</div>
</>
);
};
export default TimeClock;
-9
View File
@@ -1,9 +0,0 @@
import { z } from "zod";
export const SearchParams = z.object({
page: z.coerce.number().int().min(1).catch(1),
tab: z.enum(["yours", "global", "tag"]).default("global"),
tag: z.string().optional(),
});
export type SearchParams = z.infer<typeof SearchParams>;
+1
View File
@@ -0,0 +1 @@
<app-full-layout [class]="appClass"></app-full-layout>
+8
View File
@@ -0,0 +1,8 @@
.grayscale {
filter: grayscale(1);
transition: all 2s ease;
}
.colored {
filter: grayscale(0);
transition: all 5s ease;
}
+39
View File
@@ -0,0 +1,39 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideNativeDateAdapter } from '@angular/material/core';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [
provideRouter([]),
provideHttpClient(),
provideAnimations(),
provideNativeDateAdapter(),
],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'Ad Astra' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('Ad Astra');
});
it('should render the full layout', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('app-full-layout')).toBeTruthy();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { Component, OnInit, AfterContentInit, inject } from '@angular/core';
import { MAT_DATE_LOCALE, MAT_DATE_FORMATS, provideNativeDateAdapter } from '@angular/material/core';
import { Title } from '@angular/platform-browser';
import { FullComponent } from '@components/shared/layout';
import { UserService } from '@services';
export const MY_FORMATS = {
parse: {
dateInput: 'DD/MM/YYYY',
},
display: {
dateInput: 'DD/MM/YYYY',
monthYearLabel: 'MMM YYYY',
dateA11yLabel: 'LL',
monthYearA11yLabel: 'MMMM-YYYY',
},
};
@Component({
selector: 'app-root',
imports: [FullComponent],
providers: [
{ provide: MAT_DATE_LOCALE, useValue: 'fr-FR' },
{ provide: MAT_DATE_FORMATS, useValue: MY_FORMATS },
provideNativeDateAdapter(MY_FORMATS),
],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent implements OnInit, AfterContentInit {
private userService = inject(UserService);
private titleService = inject(Title);
public title = 'Ad Astra';
public appClass = 'grayscale';
ngOnInit() {
this.userService.populate();
this.titleService.setTitle(this.title);
}
ngAfterContentInit() {
this.appClass = 'colored';
}
}
+40
View File
@@ -0,0 +1,40 @@
import { APP_INITIALIZER, ApplicationConfig, LOCALE_ID } from '@angular/core';
import { Title } from '@angular/platform-browser';
//import { provideRouter, withComponentInputBinding, withDebugTracing } from '@angular/router';
import { provideRouter, withViewTransitions } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
import { provideCharts, withDefaultRegisterables } from 'ng2-charts';
import { EMPTY } from "rxjs";
registerLocaleData(localeFr);
import { routes } from './app.routes';
import { apiInterceptor, tokenInterceptor, errorInterceptor } from "@interceptors";
import { JwtService, UserService } from "@services";
export function initAuth(jwtService: JwtService, userService: UserService) {
return () => (jwtService.getToken() ? userService.getCurrentUser() : EMPTY);
}
export const appConfig: ApplicationConfig = {
providers: [
//provideRouter(routes, withComponentInputBinding(), withDebugTracing()),
provideRouter(routes, withViewTransitions()),
provideHttpClient(
withInterceptors([apiInterceptor, tokenInterceptor, errorInterceptor])
),
provideAnimations(),
provideCharts(withDefaultRegisterables()),
Title,
{ provide: LOCALE_ID, useValue: 'fr-FR' },
{
provide: APP_INITIALIZER,
useFactory: initAuth,
deps: [JwtService, UserService],
multi: true,
}
]
};
+5
View File
@@ -0,0 +1,5 @@
import { Routes } from '@angular/router';
import { AUTH_ROUTES, NOAUTH_ROUTES } from 'src/app/routes';
export const routes: Routes = [ ...AUTH_ROUTES, ...NOAUTH_ROUTES];
@@ -1,9 +0,0 @@
.actions {
& * + * {
margin-left: 10px;
}
}
.action-btn + .action-btn {
margin-left: 5px;
}
@@ -1,80 +0,0 @@
import { Container, Col, Row } from "react-bootstrap";
import { Button } from "@/modules/common/components/button";
import { DeleteArticleButton } from "@/modules/features/article/components/deleteArticleButton";
import { FavoriteButton } from "@/modules/features/article/components/favoriteButton";
import { Tag } from "@/modules/features/article/components/tag";
import { fetchArticle } from "@/modules/features/article/fetch/fetchArticle";
import { convertMarkdownToHtml } from "@/modules/features/article/functions";
import { FollowButton } from "@/modules/features/profile/components/followButton";
import { Article, User } from "@/utils/types/models";
import Link from "next/link";
import Image from "next/image";
import { ReactNode } from "react";
import styles from "./articleArea.module.css";
import { showDeleteArticleButton, showEditArticleButton, showFollowButton } from "./functions";
import { getSession } from "@/utils/auth/session";
import { fetchCurrentUser } from "@/modules/features/auth/fetch/fetchCurrentUser";
const Actions = ({ article, currentUser }: { article: Article; currentUser?: User }) => {
const profile = article.author;
return (
<div className="article-meta">
<Link href={`/profile/${profile.username}`}>{profile.image && <Image src={profile.image} alt="" />}</Link>
<div className="info">
<Link href={`/profile/${profile.username}`} className="author">
{profile.username}
</Link>
<span className="date">{article.createdAt.toDateString()}</span>
</div>
{showFollowButton(profile.username, currentUser) && (
<FollowButton {...profile} className={styles["action-btn"]} />
)}
<FavoriteButton {...article} showMessage={true} className={styles["action-btn"]} />
{showEditArticleButton(profile.username, currentUser) && (
<Button component="a" href={`/editor/${article.slug}`} color="secondary" className={styles["action-btn"]}>
<i className="ion-edit"></i> Edit Article
</Button>
)}
{showDeleteArticleButton(profile.username, currentUser) && (
<DeleteArticleButton slug={article.slug} className={styles["action-btn"]} />
)}
</div>
);
};
export const ArticleArea = async ({ slug, children }: { slug: string; children: ReactNode }) => {
const article = await fetchArticle(slug);
const body = await convertMarkdownToHtml(article.body);
const currentUser = (await getSession()) ? await fetchCurrentUser() : undefined;
return (
<section className="article-page">
<Container className="banner">
<h1>{article.title}</h1>
<Actions article={article} currentUser={currentUser} />
</Container>
<Container className="page">
<Row className="article-content">
<Col xs={12}>
<div dangerouslySetInnerHTML={{ __html: body }} />
<ul className="list-unstyled list-inline">
{article.tagList.map((tag, index) => (
<li className="list-inline-item me-2" key={index}>
<Tag component="badge" color="success">
{tag}
</Tag>
</li>
))}
</ul>
</Col>
</Row>
<hr />
<div className="article-actions">
<Actions article={article} currentUser={currentUser} />
</div>
{children}
</Container>
</section>
);
};
@@ -1,13 +0,0 @@
import { User } from "@/utils/types/models";
export const showFollowButton = (authorUsername: string, currentUser: User | undefined) => {
return authorUsername !== currentUser?.username;
};
export const showEditArticleButton = (authorUsername: string, currentUser: User | undefined) => {
return authorUsername === currentUser?.username;
};
export const showDeleteArticleButton = (authorUsername: string, currentUser: User | undefined) => {
return authorUsername === currentUser?.username;
};
@@ -1 +0,0 @@
export * from "./articleArea";
@@ -1,30 +0,0 @@
"use server";
import { createApiClient } from "@/utils/api/apiClient";
import { getSession } from "@/utils/auth/session";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
import { Inputs } from "./types";
export const deleteCommentAction = async (_prevState: undefined, inputs: Inputs): Promise<undefined> => {
if ((await getSession()) == null) {
redirect("/login");
}
const apiClient = createApiClient({
path: "/articles/{slug}/comments/{id}",
method: "delete",
params: {
path: inputs,
},
});
const response = await apiClient.sendRequest();
if (response.result === "success") {
revalidateTag("");
return undefined;
}
throw new Error("api error");
};
@@ -1,36 +0,0 @@
"use client";
import { Comment, User } from "@/utils/types/models";
import { use, useActionState } from "react";
import { deleteCommentAction as serverAction } from "./action";
import { CommentCard as CommentCardPresentation } from "./presentation";
import { showDeleteCommentButton } from "./functions";
type Props = {
slug: string;
comment: Comment;
currentUserPromise?: Promise<User>;
};
export const CommentCard = ({ slug, comment, currentUserPromise }: Props) => {
const [_state, dispatch, isPending] = useActionState(serverAction, undefined);
const action = () => {
if (!confirm("Delete comment?")) {
return;
}
dispatch({ slug, id: comment.id });
};
return (
<CommentCardPresentation
comment={comment}
showDeleteCommentButton={showDeleteCommentButton(
comment.author.username,
currentUserPromise && use(currentUserPromise),
)}
deleteCommentAction={action}
isPending={isPending}
/>
);
};
@@ -1,5 +0,0 @@
import { User } from "@/utils/types/models";
export const showDeleteCommentButton = (commentAuthorUsername: string, currentUser?: User) => {
return commentAuthorUsername === currentUser?.username;
};
@@ -1 +0,0 @@
export * from "./container";
@@ -1,13 +0,0 @@
.form {
display: inline;
& button {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}
}
@@ -1,39 +0,0 @@
import { Button, Form } from "react-bootstrap";
import Link from "next/link";
import Image from "next/image";
import { Comment } from "@/utils/types/models";
import styles from "./presentation.module.css";
type Props = {
comment: Comment;
showDeleteCommentButton?: boolean;
deleteCommentAction?: () => void;
isPending?: boolean;
};
export const CommentCard = ({ comment, showDeleteCommentButton, deleteCommentAction }: Props) => {
return (
<div className="card">
<div className="card-block">
<p className="card-text">{comment.body}</p>
</div>
<div className="card-footer">
<Link href={`/profile/${comment.author.username}`} className="comment-author">
{comment.author.image && <Image src={comment.author.image} className="comment-author-img" alt="" />}
</Link>
&nbsp;
<Link href={`/profile/${comment.author.username}`} className="comment-author">
{comment.author.username}
</Link>
<span className="date-posted">{comment.createdAt.toDateString()}</span>
{showDeleteCommentButton && (
<Form action={deleteCommentAction} className={styles["form"]}>
<Button className="mod-options" variant="primary" type="submit">
<i className="icon-trash" />
</Button>
</Form>
)}
</div>
</div>
);
};
@@ -1,4 +0,0 @@
export type Inputs = {
slug: string;
id: string;
};
@@ -1,57 +0,0 @@
"use server";
import { createApiClient } from "@/utils/api/apiClient";
import { getSession } from "@/utils/auth/session";
import { SubmissionResult } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
import { inputsSchema } from "./types";
export const postCommentAction = async (
_prevState: unknown,
formData: FormData,
): Promise<SubmissionResult<string[]>> => {
if ((await getSession()) == null) {
redirect("/login");
}
const submission = parseWithZod(formData, { schema: inputsSchema });
if (submission.status !== "success") {
return submission.reply();
}
const { slug, body } = submission.value;
const apiClient = createApiClient({
path: "/articles/{slug}/comments",
method: "post",
params: {
path: {
slug,
},
body: {
comment: {
body,
},
},
},
});
const response = await apiClient.sendRequest();
if (response.result === "success") {
revalidateTag("");
return submission.reply();
}
switch (response.statusCode) {
case 422:
return submission.reply({
formErrors: Object.values(response.error.errors).flat(),
});
default:
throw new Error("api error");
}
};
@@ -1,26 +0,0 @@
"use client";
import { User } from "@/utils/types/models";
import { use, useActionState } from "react";
import { postCommentAction } from "./action";
import { CommentForm as CommentFormPresentation } from "./presentation";
type Props = {
slug: string;
currentUserPromise?: Promise<User>;
};
export const CommentForm = ({ slug, currentUserPromise }: Props) => {
const currentUser = currentUserPromise && use(currentUserPromise);
const [state, action, isPending] = useActionState(postCommentAction, undefined);
return (
<CommentFormPresentation
slug={slug}
authorImage={currentUser?.image}
result={state}
postCommentAction={action}
isPending={isPending}
/>
);
};
@@ -1 +0,0 @@
export * from "./container";
@@ -1,68 +0,0 @@
import { Button, Form } from "react-bootstrap";
import Image from "next/image";
import { ErrorMessage } from "@/modules/common/components/errorMessage";
import { SubmissionResult, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { startTransition } from "react";
import { inputsSchema } from "./types";
type Props = {
slug?: string;
authorImage?: string;
result?: SubmissionResult<string[]>;
postCommentAction?: (formData: FormData) => void;
isPending?: boolean;
};
export const CommentForm = ({ slug, authorImage, result, postCommentAction, isPending }: Props) => {
const [form, fields] = useForm({
defaultValue: {
slug,
},
lastResult: result,
onValidate({ formData }) {
return parseWithZod(formData, { schema: inputsSchema });
},
// onSubmit is defined to avoid resetting form after successful submission
// see https://github.com/edmundhung/conform/discussions/606
onSubmit(event, { formData }) {
event.preventDefault();
startTransition(() => {
postCommentAction?.(formData);
});
},
shouldRevalidate: "onBlur",
});
const errors = Object.values(form.allErrors).flat();
return (
<>
<Form className="card comment-form" id={form.id} action={postCommentAction} onSubmit={form.onSubmit} noValidate={true}>
<Form.Control
type="hidden"
key={fields.slug.key}
name={fields.slug.name}
defaultValue={fields.slug.initialValue}
/>
<div className="card-block">
<textarea
key={fields.body.key}
name={fields.body.name}
defaultValue={fields.body.initialValue}
placeholder="Write a comment..."
rows={3}
className="form-control"
></textarea>
<ErrorMessage messages={errors} />
</div>
<div className="card-footer">
{authorImage && <Image src={authorImage} alt="" className="comment-author-img" />}
<Button variant="primary" type="submit" disabled={isPending}>
Post Comment
</Button>
</div>
</Form>
</>
);
};
@@ -1,8 +0,0 @@
import { z } from "zod";
export const inputsSchema = z.object({
slug: z.string().max(200),
body: z.string().max(200),
});
export type Inputs = z.infer<typeof inputsSchema>;
@@ -1,21 +0,0 @@
import { fetchComments } from "@/modules/features/article/fetch/fetchComments";
import { fetchCurrentUser } from "@/modules/features/auth/fetch/fetchCurrentUser";
import { getSession } from "@/utils/auth/session";
import { CommentCard } from "../commentCard";
type Props = {
slug: string;
};
export const CommentList = async ({ slug }: Props) => {
const comments = await fetchComments(slug);
const currentUserPromise = (await getSession()) ? fetchCurrentUser() : undefined;
return (
<>
{comments.map((comment, index) => (
<CommentCard key={index} slug={slug} comment={comment} currentUserPromise={currentUserPromise} />
))}
</>
);
};
@@ -1 +0,0 @@
export * from "./commentList";
-31
View File
@@ -1,31 +0,0 @@
import { Col, Row } from "react-bootstrap";
import { Suspense } from "react";
import { fetchCurrentUser } from "@/modules/features/auth/fetch/fetchCurrentUser";
import { getSession } from "@/utils/auth/session";
import { ArticleArea } from "./_components/articleArea";
import { CommentForm } from "./_components/commentForm";
import { CommentList } from "./_components/commentList";
type Params = Promise<{
slug: string;
}>;
const Page = async (props: { params: Params }) => {
const currentUserPromise = (await getSession()) ? fetchCurrentUser() : undefined;
const params = await props.params;
return (
<ArticleArea slug={params.slug}>
<Row>
<Col xs={12} md={{ span: 8, offset: 2 }}>
<CommentForm slug={params.slug} currentUserPromise={currentUserPromise} />
<Suspense fallback={<p>Loading comments...</p>}>
<CommentList slug={params.slug} />
</Suspense>
</Col>
</Row>
</ArticleArea>
);
};
export default Page;
-78
View File
@@ -1,78 +0,0 @@
import { Container, Col, Row } from "react-bootstrap";
import { getSession } from "@/utils/auth/session";
import { ExhaustiveError } from "@/utils/errors";
import clsx from "clsx";
import Link from "next/link";
import { Suspense } from "react";
import { FeedArticleList } from "../_components/feedArticleList";
import { GlobalArticleList } from "../_components/globalArticleList";
import { TagArticleList } from "../_components/tagArticleList";
import { TagList } from "../_components/tagList";
import { SearchParams } from "../_types";
//import { ArticlesList } from "./_components/articlesList";
const ArticleList = (searchParams: SearchParams) => {
switch (searchParams.tab) {
case "yours":
return <FeedArticleList currentPage={searchParams.page} />;
case "global":
return <GlobalArticleList currentPage={searchParams.page} />;
case "tag":
return <TagArticleList currentPage={searchParams.page} tag={searchParams.tag ?? ""} />;
default:
throw new ExhaustiveError(searchParams.tab, "all tab cases are not covered");
}
};
const Page = async (props: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) => {
const session = await getSession();
const searchParams = SearchParams.parse(await props.searchParams);
return (
<section className="auth-page">
<Container className="page">
<hr className="col-1 my-5 mx-0" />
<Row>
<Col md={9}>
<div className="feed-toggle">
<ul className="nav nav-pills outline-active">
{session && (
<li className="nav-item">
<Link className={clsx("nav-link", searchParams.tab === "yours" && "active")} href="/?tab=yours">
Your Feed
</Link>
</li>
)}
<li className="nav-item">
<Link className={clsx("nav-link", searchParams.tab === "global" && "active")} href="/?tab=global">
Global Feed
</Link>
</li>
{searchParams.tag && (
<li className="nav-item">
<Link
className={clsx("nav-link", searchParams.tab === "tag" && "active")}
href={`/?tab=tag&tag=${searchParams.tag}`}
>
#{searchParams.tag}
</Link>
</li>
)}
</ul>
</div>
<Suspense key={JSON.stringify(searchParams)} fallback={<p>Chargement...</p>}>
<ArticleList {...searchParams} />
</Suspense>
</Col>
<Col md={3}>
<Suspense fallback={<p>Chargement...</p>}>
<TagList />
</Suspense>
</Col>
</Row>
</Container>
</section>
);
};
export default Page;
@@ -0,0 +1,49 @@
<div @flyInOut>
<div class="d-flex">
<div class="flex-fill">
<h1 class="mb-1">{{title}}</h1>
<span [hidden]="!lastJump.numero" class="me-2 fs-6">
Dernier saut enregistré : <span class="fs-5 mx-1 text-primary">{{ lastJump.numero }}<sup>ème</sup></span> à <span class="fs-5 mx-1 text-primary">{{ lastJump.lieu }}</span> le <span class="fs-5 mx-1 text-primary">{{ lastJump.date | date: 'dd/MM/yyyy' }}</span>
</span>
</div>
<span class="flex-spacer"></span>
<div class="align-self-center me-2">
<button mat-icon-button [matMenuTriggerFor]="menuAeronef" aria-label="Menu Aeronef">
<mat-icon fontIcon="more_vert"></mat-icon>
</button>
<mat-menu #menuAeronef="matMenu">
@for (menuitem of menuItems.getMenuPanel(); track menuitem) {
<button mat-menu-item>
<mat-icon [fontIcon]="menuitem.icon"></mat-icon>
<span>{{ menuitem.name }}</span>
</button>
}
</mat-menu>
</div>
</div>
<mat-divider class="my-3"></mat-divider>
<mat-card>
<mat-card-header>
<mat-card-title>{{ subtitle }}</mat-card-title>
</mat-card-header>
<mat-card-content class="mt-4">
@if (displayCharts) {
<div class="row">
<div class="col-xl-4 col-lg-5 col-md-6 col-sm-12">
<app-pie-chart [headers]="seriesHeader" [names]="seriesName" [values]="seriesValue" [colors]="seriesColor"></app-pie-chart>
</div>
<div class="col-xl-8 col-lg-7 col-md-6 col-sm-12">
<!--<app-bar-chart [headers]="seriesHeader" [names]="seriesName" [values]="seriesValue" [colors]="seriesColor" label="Nombre total de sauts" [legend]="false"></app-bar-chart>-->
<app-bars-chart [headers]="seriesHeader" [names]="seriesName" [values]="seriesRow" [colors]="seriesColor" [legend]="false"></app-bars-chart>
</div>
</div>
}
<div class="d-flex">
<div class="flex-fill py-2">
<mat-divider class="mt-3 mb-0"></mat-divider>
<app-history-table [headers]="seriesHeader" [names]="seriesName" [values]="seriesValue" [rows]="seriesRow" [colors]="seriesColorClass"></app-history-table>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,33 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { MenuItems } from '@components/shared';
import { AeronefsComponent } from './aeronefs.component';
describe('AeronefsComponent', () => {
let component: AeronefsComponent;
let fixture: ComponentFixture<AeronefsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AeronefsComponent],
providers: [
provideRouter([]),
provideHttpClient(),
provideAnimations(),
MenuItems,
],
})
.compileComponents();
fixture = TestBed.createComponent(AeronefsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,116 @@
import { DatePipe } from '@angular/common';
import { trigger, state, style, animate, transition } from '@angular/animations';
import { Component, DestroyRef, inject, OnInit, OnDestroy } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatGridListModule } from '@angular/material/grid-list';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { Observable, Subscription } from 'rxjs';
import { MenuItems } from '@components/shared';
import { HistoryTableComponent } from '@components/shared/helpers-jump';
import { BarsChartComponent, PieChartComponent } from '@components/shared/helpers-chart';
import { Jump, AeronefsPageData } from '@models';
import { UtilitiesService } from '@services';
import { AeronefByImat, AeronefByYear } from '@models';
@Component({
selector: 'app-aeronefs',
imports: [
DatePipe,
MatBadgeModule,
MatButtonModule,
MatCardModule,
MatDividerModule,
MatExpansionModule,
MatGridListModule,
MatIconModule,
MatMenuModule,
BarsChartComponent,
HistoryTableComponent,
PieChartComponent,
],
templateUrl: './aeronefs.component.html',
styleUrl: './aeronefs.component.scss',
animations: [
trigger('flyInOut', [
state('in', style({ transform: 'translateX(0)' })),
transition('void => *', [style({ transform: 'translateX(-100%)' }), animate(200)]),
transition('* => void', [style({ transform: 'translateX(100%)' }), animate(200)]),
]),
],
})
export class AeronefsComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private _utilitiesService = inject(UtilitiesService);
menuItems = inject(MenuItems);
private _data: Subscription = new Subscription();
private _aeronefsByImat!: Array<AeronefByImat>;
private _aeronefsByYear!: Array<AeronefByYear>;
public lastJump: Jump = {} as Jump;
public title = 'Aéronefs';
public subtitle: string = 'Nombre total de sauts par aéronef';
public displayCharts = false;
public destroyRef = inject(DestroyRef);
public seriesHeader: string[] = [];
public seriesName: string[] = [];
public seriesValue: number[] = [];
public seriesRow: Array<Array<number>> = [];
public seriesColor: {
backgroundColor: string[];
borderColor: string[];
} = {
backgroundColor: this._utilitiesService.getSeriesColors(0.8, 'all'),
borderColor: this._utilitiesService.getSeriesColors(1, 'all'),
};
public seriesColorClass: string[] = this._utilitiesService.getChartColors();
ngOnInit() {
const data$: Observable<{ aeronefsPageData: AeronefsPageData }> = this.route.data as Observable<{
aeronefsPageData: AeronefsPageData;
}>;
this._data = data$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data: { aeronefsPageData: AeronefsPageData }) => {
const pageData: AeronefsPageData = data.aeronefsPageData;
this.lastJump = pageData.lastjump;
this._loadAeronefByImat(pageData);
});
}
ngOnDestroy() {
this._data.unsubscribe();
}
private _loadAeronefByImat(pageData: AeronefsPageData): void {
this._aeronefsByImat = pageData.aeronefsByImat.map((row: AeronefByImat) => {
this.seriesName.push(`${row.aeronef} ${row.imat}`);
this.seriesValue.push(row.count);
return row;
});
this._loadAeronefByYear(pageData);
}
private _loadAeronefByYear(pageData: AeronefsPageData): void {
this.seriesHeader = ['2018', '2019', '2020', '2021', '2022', '2023', '2024'];
const values: Array<number> = this.seriesHeader.map(() => 0);
this.seriesName.forEach(() => {
this.seriesRow.push([...values]);
});
this._aeronefsByYear = pageData.aeronefsByImatByYear.map((row: AeronefByYear) => {
const aeronef: string = row.aeronef;
const imat: string = row.imat;
const year: string = row.year.toString();
this.seriesRow[this.seriesName.indexOf(`${aeronef} ${imat}`)][this.seriesHeader.indexOf(year)] = row.count;
return row;
});
this.displayCharts = true;
}
}
+113
View File
@@ -0,0 +1,113 @@
<div class="container auth-page">
<div class="row">
<div class="col-lg-8 offset-lg-2 col-xl-6 offset-xl-3 col-xs-12">
<h1 class="text-xs-center">{{ title }}</h1>
<p class="text-xs-center">
@if (authType === 'register') {
<a [routerLink]="['/login']">Vous avez déjà un compte ?</a>
}
@if (authType === 'login') {
<a [routerLink]="['/register']">Vous n'avez pas encore de compte ?</a>
}
</p>
<app-list-errors [errors]="errors"></app-list-errors>
<form [formGroup]="authForm" (ngSubmit)="submitForm()">
<fieldset [disabled]="isSubmitting" class="w-100">
<legend [hidden]="true">Informations personnelles</legend>
<div class="col-12">
<mat-form-field appearance="fill" class="w-100">
<input matInput type="text" placeholder="Votre adresse email" formControlName="email" required>
@if (this.authForm.controls['email'].status === 'INVALID') {
<mat-error>{{getErrorMessage('email')}}</mat-error>
}
</mat-form-field>
</div>
@if (authType === 'register') {
<div class="col-12">
<mat-form-field appearance="fill" class="w-100">
<input matInput type="text" placeholder="Votre nom" formControlName="lastname" required>
@if (this.authForm.controls['lastname'].status === 'INVALID') {
<mat-error>{{getErrorMessage('lastname')}}</mat-error>
}
</mat-form-field>
</div>
}
@if (authType === 'register') {
<div class="col-12">
<mat-form-field appearance="fill" class="w-100">
<input matInput type="text" placeholder="Votre prénom" formControlName="firstname" required>
@if (this.authForm.controls['firstname'].status === 'INVALID') {
<mat-error>{{getErrorMessage('firstname')}}</mat-error>
}
</mat-form-field>
</div>
}
@if (authType === 'register') {
<div class="col-12">
<mat-form-field appearance="fill" class="w-100">
<input matInput type="text" placeholder="Numéro de téléphone" formControlName="phone" required>
@if (this.authForm.controls['phone'].status === 'INVALID') {
<mat-error>{{getErrorMessage('phone')}}</mat-error>
}
</mat-form-field>
</div>
}
@if (authType === 'register') {
<div class="col-12">
<mat-form-field appearance="fill" class="w-100">
<input matInput type="text" placeholder="Pseudo" formControlName="username">
@if (this.authForm.controls['username'].status === 'INVALID') {
<mat-error>{{getErrorMessage('username')}}</mat-error>
}
</mat-form-field>
</div>
}
@if (authType === 'register') {
<div class="col-12">
<mat-form-field appearance="fill" class="w-100">
<input matInput type="text" placeholder="Numéro de licence FFP" formControlName="licence">
@if (this.authForm.controls['licence'].status === 'INVALID') {
<mat-error>{{getErrorMessage('licence')}}</mat-error>
}
</mat-form-field>
</div>
}
@if (authType === 'register') {
<div class="col-12">
<mat-form-field appearance="fill" class="w-100">
<input matInput type="text" placeholder="Indiquez le poids en Kg" formControlName="poids">
@if (this.authForm.controls['poids'].status === 'INVALID') {
<mat-error>{{getErrorMessage('poids')}}</mat-error>
}
</mat-form-field>
</div>
}
</fieldset>
<fieldset [disabled]="isSubmitting" class="w-100">
<legend [hidden]="true">Informations de connexion</legend>
<div class="col-12">
<mat-form-field appearance="fill" class="w-100">
<input matInput type="password" placeholder="Mot de passe" formControlName="password" required>
@if (this.authForm.controls['password'].status === 'INVALID') {
<mat-error>{{getErrorMessage('password')}}</mat-error>
}
</mat-form-field>
</div>
@if (authType === 'register') {
<div class="col-12">
<mat-form-field appearance="fill" class="w-100">
<input matInput type="password" placeholder="Confirmez votre nouveau mot de passe" formControlName="confirmPassword" required>
@if (this.authForm.controls['confirmPassword'].status === 'INVALID') {
<mat-error>{{getErrorMessage('confirmPassword')}}</mat-error>
}
</mat-form-field>
</div>
}
<div class="col-12">
<button mat-raised-button color="primary" [disabled]="!authForm.valid" type="submit" class="float-sm-end">{{ btnTitle }}</button>
</div>
</fieldset>
</form>
</div>
</div>
</div>
@@ -0,0 +1,6 @@
fieldset {
border-style: none;
border-width: 0;
margin: 0;
padding: 0;
}
@@ -0,0 +1,29 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { AuthComponent } from './auth.component';
describe('AuthComponent', () => {
let component: AuthComponent;
let fixture: ComponentFixture<AuthComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AuthComponent],
providers: [
provideRouter([]),
provideHttpClient(),
provideAnimations(),
],
});
fixture = TestBed.createComponent(AuthComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+175
View File
@@ -0,0 +1,175 @@
import { Component, DestroyRef, inject, OnInit, OnDestroy } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
UntypedFormBuilder,
UntypedFormGroup,
AbstractControl,
Validators,
ValidatorFn,
ValidationErrors,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { Subscription } from 'rxjs';
import { take } from 'rxjs/operators';
import { Errors } from '@models';
import { UserService } from '@services';
import { ListErrorsComponent } from '@components/shared';
@Component({
imports: [
RouterLink,
FormsModule,
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
ListErrorsComponent,
],
selector: 'app-auth',
templateUrl: './auth.component.html',
styleUrl: './auth.component.scss',
})
export class AuthComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private titleService = inject(Title);
private userService = inject(UserService);
private fb = inject(UntypedFormBuilder);
private _url: Subscription = new Subscription();
private _user: Subscription = new Subscription();
authType = '';
title = '';
btnTitle = '';
errors: Errors = { errors: {} };
isSubmitting = false;
authForm!: UntypedFormGroup;
destroyRef = inject(DestroyRef);
constructor() {
this._resetErrors();
// use FormBuilder to create a form group
const controlsConfig = {
email: ['', [Validators.required, Validators.email]],
username: '',
firstname: '',
lastname: '',
phone: '',
licence: '',
poids: '',
password: ['', Validators.required],
confirmPassword: '',
};
this.authForm = this.fb.group(controlsConfig, { validators: this.checkPasswords });
}
ngOnInit() {
// you would normally unsubscribe from this observable subscription
// the active route observables are exemptions from unsubribe always rule
// see notes on: https://angular.io/guide/router#observable-parammap-and-component-reuse
this.route.url.pipe(take(1)).subscribe((data) => {
// Get the last piece of the URL (it's either 'login' or 'register')
this.authType = data[data.length - 1].path;
// Set a title for the page accordingly
if (this.authType === 'register') {
this.title = 'Création de compte';
this.btnTitle = 'Créer le compte';
this.titleService.setTitle(`Ad Astra - Créer un compte`);
} else {
this.title = 'Se connecter';
this.btnTitle = 'Se connecter';
this.titleService.setTitle(`Ad Astra - Connexion`);
}
// add form control for username if this is the register page
if (this.authType === 'register') {
this.authForm.controls['firstname'].setValidators([Validators.required]);
this.authForm.controls['lastname'].setValidators([Validators.required]);
this.authForm.controls['phone'].setValidators([Validators.required]);
this.authForm.controls['confirmPassword'].setValidators([Validators.minLength(8), Validators.required]);
}
});
}
ngOnDestroy() {
this._user.unsubscribe();
//this._url.unsubscribe();
}
submitForm() {
this.isSubmitting = true;
this.errors = { errors: {} };
if (this.authForm.valid) {
this._resetErrors();
const credentials = this.authForm.value;
const user$ = this.userService
.attemptAuth(this.authType, credentials)
.pipe(takeUntilDestroyed(this.destroyRef));
this._user = user$.subscribe({
next: () => void this.router.navigateByUrl('/'),
error: (err) => {
console.log(err);
this.errors = err;
this.isSubmitting = false;
},
});
}
}
getErrorMessage(name: string) {
switch (name) {
case 'email':
if (this.authForm.controls['email'].errors !== null) {
return 'Indiquez votre adresse email';
}
break;
case 'firstname':
if (this.authForm.controls['firstname'].errors !== null) {
return 'Vous devez indiquer votre prénnom';
}
break;
case 'lastname':
if (this.authForm.controls['lastname'].errors !== null) {
return 'Vous devez indiquer votre nom';
}
break;
case 'phone':
if (this.authForm.controls['phone'].errors !== null) {
return 'Vous devez indiquer votre numéro de téléphone';
}
break;
case 'password':
if (this.authForm.controls['password'].errors !== null) {
return 'Un mot de passe est obligatoire';
}
break;
case 'confirmPassword':
if (this.authForm.controls['confirmPassword'].errors !== null) {
return 'Une confirmation du mot de passe est obligatoire';
}
break;
}
return '';
}
checkPasswords: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
if (this.authType !== 'register') {
return null;
}
const pass = group.get('password')!.value;
const confirmPass = group.get('confirmPassword')!.value;
return pass === confirmPass && pass !== '' ? null : { notSame: true };
};
private _resetErrors(): void {
this.errors = { errors: {} };
}
}
@@ -0,0 +1,257 @@
<div @flyInOut>
<div class="d-flex">
<div class="flex-fill">
<h1 class="mb-1">{{title}}</h1>
<span class="me-2 fs-6"><span class="fs-5 mx-1">DT48</span> - 08 février 2024</span>
</div>
<span class="flex-spacer"></span>
<div class="align-self-center me-2">
<button mat-icon-button [matMenuTriggerFor]="menuCalculator" aria-label="Menu Calculator">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #menuCalculator="matMenu">
@for (menuitem of menuItems.getMenuCalculator(); track menuitem) {
@if (menuitem.type === 'link') {
<button mat-menu-item [routerLink]="['/', menuitem.state]">
<mat-icon [fontIcon]="menuitem.icon"></mat-icon>
<span>{{ menuitem.name }}</span>
</button>
}
}
</mat-menu>
</div>
</div>
<mat-divider class="my-3"></mat-divider>
<mat-card class="calculator">
<mat-card-header class="bg-warn rounded-top pt-1 pb-2 px-2">
<form #searchFormData="ngForm" class="inputs-form">
<mat-form-field class="bg-crystal me-3" subscriptSizing="dynamic" color="accent">
<mat-label>Nombre de sauts</mat-label>
<input matInput [(ngModel)]="inputs.jumps" (change)="refresh()" type="number" min="0" max="2000"
step="1" name="jumps">
</mat-form-field>
<mat-form-field class="bg-crystal me-3" subscriptSizing="dynamic" color="accent">
<mat-label>Poids nu</mat-label>
<input matInput [(ngModel)]="inputs.weight" (change)="refresh()" type="number" min="30"
max="110" name="weight">
</mat-form-field>
<mat-form-field class="bg-crystal me-3" subscriptSizing="dynamic" color="accent">
<mat-label>Poids équipement</mat-label>
<input matInput [(ngModel)]="inputs.gear" (change)="refresh()" type="number" min="0"
name="gear">
</mat-form-field>
<mat-form-field class="bg-crystal me-3" subscriptSizing="dynamic" color="accent">
<mat-label>Taille actuelle</mat-label>
<input matInput [(ngModel)]="inputs.current" (change)="refresh()" type="number" min="0"
name="current" id="current" placeholder="Taille actuelle">
</mat-form-field>
</form>
<span class="flex-spacer"></span>
<div class="flex-fill"></div>
</mat-card-header>
<mat-card-content class="my-4">
<div class="row">
<div class="col-lg-6 col-md-8 col-sm-12">
<table class="table table-striped table-dark table-hover mat-elevation-z2">
<thead>
<tr class="warn">
<th class="text-nowrap pe-4"></th>
<th class="text-nowrap">Taille actuelle</th>
<th class="text-nowrap">Taille min</th>
<th class="text-nowrap">Taille min -11%</th>
</tr>
</thead>
<tbody>
<tr class="warn">
<th class="text-nowrap pe-4">Taille de voile en ft<sup>2</sup></th>
<td class="font-monospace text-{{ info.state.color }}">
{{ info.sizesFeet.current }} </td>
<td class="font-monospace text-success"> ● {{ info.sizesFeet.min }} </td>
<td class="font-monospace text-warning"> ● {{ info.sizesFeet.min11 }} </td>
</tr>
<tr class="warn">
<th class="text-nowrap pe-4">Charge alaire</th>
<td class="font-monospace">{{ info.charges.current | number : '1.2-3' }}</td>
<td class="font-monospace">{{ info.charges.min | number : '1.2-3' }}</td>
<td class="font-monospace">{{ info.charges.min11 | number : '1.2-3' }}</td>
</tr>
<tr class="warn">
<th class="text-nowrap pe-4">Taille de voile en m<sup>2</sup></th>
<td class="font-monospace">{{ info.sizesMeter.current | number : '1.2-3' }}</td>
<td class="font-monospace">{{ info.sizesMeter.min | number : '1.2-3' }}</td>
<td class="font-monospace">{{ info.sizesMeter.min11 | number : '1.2-3' }}</td>
</tr>
<tr class="warn">
<th class="text-nowrap pe-4">Coefficient kilo/livres</th>
<td colspan="3" class="font-monospace text-start">{{ coeffKgLbs }}</td>
</tr>
<tr class="warn">
<th class="text-nowrap pe-4">Coefficient pied/mètre</th>
<td colspan="3" class="font-monospace text-start">{{ coeffFtM }}</td>
</tr>
<tr class="warn">
<th class="text-nowrap pe-4">Formule charge alaire</th>
<td colspan="3" class="font-monospace text-start">
( ( {{ inputs.weight }} + {{ inputs.gear }} ) * {{ coeffKgLbs }} ) /
{{ inputs.current }} = {{ info.charges.current | number : '1.5' }}<br />
( ( poids nu en kg + poids equipement en kg ) * coefficient kg/lbs ) / taille de
voile en ft<sup>2</sup>
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-lg-6 col-md-8 col-sm-12">
@if (displayCharts) {
<div class="linechart position-relative w-100 mt-3">
<canvas baseChart class="mx-auto" width="800" height="280"
[data]="chartConfig.lineChartData" [options]="chartConfig.lineChartOptions"
[legend]="chartConfig.lineChartLegend" [type]="'line'">
</canvas>
</div>
}
</div>
</div>
<mat-divider class="my-3"></mat-divider>
<h4 class="py-2 mb-0">Tailles de voile pour {{ inputs.weight }} kg</h4>
<div>
Prochain changement de valeurs dans {{ ((this.currentRange.end + 1) - inputs.jumps) }}
{{ ((this.currentRange.end + 1) - inputs.jumps) > 1 ? 'sauts' : 'saut'}}<br />
</div>
<div class="text-end">
{{ ((((inputs.jumps - this.currentRange.start) + 1) / ((this.currentRange.end - this.currentRange.start) + 1)) * 100) | number : '1.2-2' }}%
de la tranche actuelle ({{this.currentRange.start}} à {{this.currentRange.end}})
</div>
<mat-progress-bar mode="determinate"
[value]="((((inputs.jumps - this.currentRange.start) + 1) / ((this.currentRange.end - this.currentRange.start) + 1)) * 100)"
color="warn"></mat-progress-bar>
<table class="table table-striped table-dark table-hover mat-elevation-z2 mb-1">
<thead>
<tr class="warn">
<th class="text-nowrap pe-4"></th>
@for (header of sizesHeader; track header; let index = $index) {
<th class="pl-3 pr-3 {{ tableHeader[(index+1)].active }}">{{ header.name }}</th>
}
</tr>
</thead>
<tbody>
<tr class="warn">
<th class="firtext-nowrap pe-4st">Taille de voile en ft<sup>2</sup></th>
@for (value of sizesValuesFeet; track value; let index = $index) {
<td class="pl-3 text-nowrap {{ tableHeader[(index+1)].active }}">{{ value }}</td>
}
</tr>
<tr class="warn">
<th class="text-nowrap pe-4">Abbattement 11%</th>
@for (value of sizesMinValuesFeet; track value; let index = $index) {
<td class="pl-3 text-nowrap {{ tableHeader[(index+1)].active }}">{{ value }}</td>
}
</tr>
<tr class="warn">
<th class="text-nowrap pe-4">Charge alaire</th>
@for (charge of sizesCharges; track charge; let index = $index) {
<td class="pl-3 text-nowrap {{ tableHeader[(index+1)].active }}">
{{ charge | number : '1.2-3' }}</td>
}
</tr>
<tr class="warn">
<th class="text-nowrap pe-4">Taille de voile en m<sup>2</sup></th>
@for (value of sizesValuesMeter; track value; let index = $index) {
<td class="pl-3 text-nowrap {{ tableHeader[(index+1)].active }}">
{{ value | number : '1.2-2' }}</td>
}
</tr>
</tbody>
</table>
<mat-divider class="my-3"></mat-divider>
<h4 class="py-2 mb-0">Tableau des tailles de voile par poids</h4>
<div class="text-end">
{{ ((inputs.jumps / 1600) * 100) | number : '1.2-2' }}% de la plage complète (0 à 1600)
</div>
<mat-progress-bar mode="determinate" [value]="((inputs.jumps / 1600) * 100)" color="warn">
</mat-progress-bar>
<table mat-table [dataSource]="canopy_size_table" matSort
class="table table-striped table-dark table-hover mat-elevation-z2">
<!-- weight column -->
<ng-container matColumnDef="weight">
<th mat-header-cell *matHeaderCellDef mat-sort-header class="text-nowrap pe-4 {{tableHeader[0].active}}">
{{tableHeader[0].name}} </th>
<th mat-cell *matCellDef="let element;" class="text-end ps-3 pe-4 fs-6" role="button"
(click)="setCurrentWeight(element.weight)">
<span class="text-warn d-block">{{element.weight}}</span>
</th>
</ng-container>
<!-- range_1 column -->
<ng-container matColumnDef="range_1">
<th class="pl-3" mat-header-cell *matHeaderCellDef class="{{tableHeader[1].active}}">
{{tableHeader[1].name}} </th>
<td mat-cell *matCellDef="let element;"
class="pl-3 text-nowrap fs-6 {{element.ranges[0].active}}">{{element.ranges[0].value}}</td>
</ng-container>
<!-- range_2 column -->
<ng-container matColumnDef="range_2">
<th class="pl-3" mat-header-cell *matHeaderCellDef class="{{tableHeader[2].active}}">
{{tableHeader[2].name}} </th>
<td mat-cell *matCellDef="let element;"
class="pl-3 text-nowrap fs-6 {{element.ranges[1].active}}">{{element.ranges[1].value}}</td>
</ng-container>
<!-- range_3 column -->
<ng-container matColumnDef="range_3">
<th class="pl-3" mat-header-cell *matHeaderCellDef class="{{tableHeader[3].active}}">
{{tableHeader[3].name}} </th>
<td mat-cell *matCellDef="let element;"
class="pl-3 text-nowrap fs-6 {{element.ranges[2].active}}">{{element.ranges[2].value}}</td>
</ng-container>
<!-- range_4 column -->
<ng-container matColumnDef="range_4">
<th class="pl-3" mat-header-cell *matHeaderCellDef class="{{tableHeader[4].active}}">
{{tableHeader[4].name}} </th>
<td mat-cell *matCellDef="let element;"
class="pl-3 text-nowrap fs-6 {{element.ranges[3].active}}">{{element.ranges[3].value}}</td>
</ng-container>
<!-- range_5 column -->
<ng-container matColumnDef="range_5">
<th class="pl-3" mat-header-cell *matHeaderCellDef class="{{tableHeader[5].active}}">
{{tableHeader[5].name}} </th>
<td mat-cell *matCellDef="let element;"
class="pl-3 text-nowrap fs-6 {{element.ranges[4].active}}">{{element.ranges[4].value}}</td>
</ng-container>
<!-- range_6 column -->
<ng-container matColumnDef="range_6">
<th class="pl-3" mat-header-cell *matHeaderCellDef class="{{tableHeader[6].active}}">
{{tableHeader[6].name}} </th>
<td mat-cell *matCellDef="let element;"
class="pl-3 text-nowrap fs-6 {{element.ranges[5].active}}">{{element.ranges[5].value}}</td>
</ng-container>
<!-- range_7 column -->
<ng-container matColumnDef="range_7">
<th class="pl-3" mat-header-cell *matHeaderCellDef class="{{tableHeader[7].active}}">
{{tableHeader[7].name}} </th>
<td mat-cell *matCellDef="let element;"
class="pl-3 text-nowrap fs-6 {{element.ranges[6].active}}">{{element.ranges[6].value}}</td>
</ng-container>
<!-- range_8 column -->
<ng-container matColumnDef="range_8">
<th class="pl-3" mat-header-cell *matHeaderCellDef class="{{tableHeader[8].active}}">
{{tableHeader[8].name}} </th>
<td mat-cell *matCellDef="let element;"
class="pl-3 text-nowrap fs-6 {{element.ranges[7].active}}">{{element.ranges[7].value}}</td>
</ng-container>
<!-- range_9 column -->
<ng-container matColumnDef="range_9">
<th class="pl-3" mat-header-cell *matHeaderCellDef class="{{tableHeader[9].active}}">
{{tableHeader[9].name}} </th>
<td mat-cell *matCellDef="let element;"
class="pl-3 text-nowrap fs-6 {{element.ranges[8].active}}">{{element.ranges[8].value}}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: false" class="warn"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"
class="text-middle font-monospace warn {{row.active}}" id="weight_{{row.weight}}"></tr>
</table>
<div class="clearfix">
<mat-paginator [pageSize]="50" [pageSizeOptions]="[5, 10, 15, 25, 50]" showFirstLastButtons
class="pull-right mb-1 mt-2"></mat-paginator>
</div>
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,33 @@
.calculator-page {
section {
display: table;
}
.demo-label {
display: table-cell;
font-size: 14px;
margin-left: 8px;
min-width: 120px;
}
.demo-button-row {
display: table-cell;
max-width: 900px;
}
.demo-button-row .mat-mdc-button-base {
margin: 8px 8px 8px 0;
}
.demo-flex-container {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.demo-button-container {
display: flex;
justify-content: center;
width: 120px;
}
th {
&.first {
width: 180px;
}
}
}
@@ -0,0 +1,33 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { Chart, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, LineElement, PointElement } from 'chart.js';
Chart.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, LineElement, PointElement);
import { MenuItems } from '@components/shared';
import { CalculatorComponent } from './calculator.component';
describe('CalculatorComponent', () => {
let component: CalculatorComponent;
let fixture: ComponentFixture<CalculatorComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CalculatorComponent],
providers: [
provideRouter([]),
provideHttpClient(),
provideAnimations(),
MenuItems,
],
});
fixture = TestBed.createComponent(CalculatorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,232 @@
import { Component, Input, OnInit, OnDestroy, ViewChild, inject } from '@angular/core';
import { trigger, state, style, animate, transition } from '@angular/animations';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { BaseChartDirective } from 'ng2-charts';
import { Observable, Subscription } from 'rxjs';
import { MenuItems } from '@components/shared';
import {
CalculatorInfo,
CalculatorResult,
CalculatorState,
InputParams,
Jump,
LineConfig,
WeightSizeRange,
TableHeader,
User,
WeightSize,
weightSizes,
} from '@models';
import { CalculatorService, JumpsService, UserService, UtilitiesService } from '@services';
@Component({
selector: 'app-calculator',
imports: [
CommonModule,
FormsModule,
RouterModule,
MatButtonModule,
MatCardModule,
MatDividerModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatMenuModule,
MatPaginatorModule,
MatProgressBarModule,
MatSortModule,
MatTableModule,
BaseChartDirective,
],
templateUrl: './calculator.component.html',
styleUrl: './calculator.component.scss',
animations: [
trigger('flyInOut', [
state('in', style({ transform: 'translateY(0)' })),
transition('void => *', [style({ transform: 'translateY(-100%)' }), animate(200)]),
transition('* => void', [style({ transform: 'translateY(100%)' }), animate(200)]),
]),
],
})
export class CalculatorComponent implements OnInit, OnDestroy {
private _calculatorService = inject(CalculatorService);
private _jumpsService = inject(JumpsService);
private _userService = inject(UserService);
private _utilitiesService = inject(UtilitiesService);
menuItems = inject(MenuItems);
//private _lastjump!: Subscription; // = new Subscription();
//private _currentUser!: Subscription; // = new Subscription();
private _subscriptions: Array<Subscription> = [];
public title = 'Calcul de taille de voile';
public subtitle = 'DT48 - 08 février 2024';
public lastjump: Jump = {} as Jump;
public currentRange: WeightSizeRange = {} as WeightSizeRange;
public currentUser: User = {} as User;
public displayedColumns: Array<string> = ['weight'];
public tableHeader: Array<TableHeader> = [<TableHeader>{ name: 'Poids nu en kg', active: '' }];
public sizesHeader: Array<TableHeader> = [];
public sizesValuesFeet: Array<number> = [];
public sizesMinValuesFeet: Array<number> = [];
public sizesCharges: Array<number> = [];
public sizesValuesMeter: Array<number> = [];
public displayCharts = false;
public chartConfig: LineConfig = this._utilitiesService.getLineChartConfig();
public coeffKgLbs: number = this._utilitiesService.getCoeffKgLbs();
public coeffFtM: number = this._utilitiesService.getCoeffFtM();
public info: CalculatorInfo = {
charges: <CalculatorResult>{ current: 0, min: 0, min11: 0 },
sizesFeet: <CalculatorResult>{ current: 0, min: 0, min11: 0 },
sizesMeter: <CalculatorResult>{ current: 0, min: 0, min11: 0 },
state: <CalculatorState>{ color: 'danger' },
};
@Input() canopy_size_table!: MatTableDataSource<WeightSize>;
@Input() inputs: InputParams = (<InputParams>{
jumps: 0,
weight: 60,
gear: 10,
current: 190,
}) as InputParams;
@ViewChild(MatSort) sort!: MatSort;
@ViewChild(MatPaginator) paginator!: MatPaginator;
ngOnInit() {
const currentUser$: Observable<User> = this._userService.currentUser;
const lastjump$: Observable<Jump> = this._jumpsService.getLastJump();
weightSizes[0].ranges.forEach((range: WeightSizeRange) => {
const header: TableHeader = { name: range.label, active: range.active };
this.tableHeader.push(header);
this.sizesHeader.push(header);
this.displayedColumns.push(range.name);
});
this.chartConfig.lineChartData.labels = [...this.sizesHeader.map((data: TableHeader): string => data.name)];
this.chartConfig.lineChartData.datasets = [
{
label: 'Tailles min -11%',
data: this.sizesMinValuesFeet,
fill: true,
stepped: true,
pointStyle: false,
borderColor: 'rgba(241, 80, 80, 1)',
backgroundColor: 'rgba(241, 80, 80, 0.3)',
},
{
label: 'Tailles min',
data: this.sizesValuesFeet,
fill: true,
stepped: true,
pointStyle: false,
borderColor: 'rgba(32, 182, 252, 1)',
backgroundColor: 'rgba(32, 182, 252, 0.3)',
},
];
this._subscriptions.push(
currentUser$.subscribe((userData: User) => {
this.currentUser = userData;
this._subscriptions.push(
lastjump$.subscribe((jump) => {
this.lastjump = jump;
this.inputs.jumps = jump.numero;
this.inputs.weight = this.currentUser.poids ?? this.inputs.weight;
this.inputs.current = jump.taille!;
this.canopy_size_table = new MatTableDataSource<WeightSize>(weightSizes);
this.refresh();
}),
);
}),
);
}
ngOnDestroy() {
this._subscriptions.forEach((sub: Subscription) => {
sub.unsubscribe();
});
}
public refresh(): void {
this._refreshActive();
this._refreshSizes();
}
private _refreshActive(): void {
let weight: number = this.inputs.weight;
if (this.inputs.weight < 60) {
weight = 60;
}
weightSizes.forEach((element) => {
element.active = '';
element.ranges.forEach((range) => {
range.active = '';
});
});
this.tableHeader.forEach((element) => {
element.active = '';
});
if (weight >= 60 && weight <= 110) {
weightSizes[weight - 60].active = 'active';
}
if (this.inputs.jumps >= 0) {
const num = this._calculatorService.getRangeNum(this.inputs.jumps);
this.tableHeader[num].active = 'active';
weightSizes.forEach((element) => {
element.ranges[num - 1].active = 'active';
});
this.currentRange = weightSizes[0].ranges[num - 1];
}
}
private _refreshSizes(): void {
let weight: number = this.inputs.weight;
if (this.inputs.weight < 60) {
weight = 60;
}
if (this.inputs.jumps > 2000) {
this.info.state.error = '2K+ Jumps';
}
this.sizesValuesFeet = this._calculatorService.getCanopySizes(weight);
this.sizesMinValuesFeet = this._calculatorService.getCanopySizes(weight, true);
this.sizesValuesMeter = this.sizesValuesFeet.map((value: number, index: number) => {
this.sizesCharges[index] = this._calculatorService.getCharge(value, this.inputs.weight, this.inputs.gear);
return this._calculatorService.convertFeet2Meters(value);
});
this.info.sizesFeet = this._calculatorService.canopySizeCalc(weight, this.inputs.jumps);
this.info.sizesFeet.current = this.inputs.current;
this.info.sizesMeter = {
current: this._calculatorService.convertFeet2Meters(this.inputs.current),
min: this._calculatorService.convertFeet2Meters(this.info.sizesFeet.min),
min11: this._calculatorService.convertFeet2Meters(this.info.sizesFeet.min11),
};
this.info.charges = {
current: this._calculatorService.getCharge(this.inputs.current, this.inputs.weight, this.inputs.gear),
min: this._calculatorService.getCharge(this.info.sizesFeet.min, this.inputs.weight, this.inputs.gear),
min11: this._calculatorService.getCharge(this.info.sizesFeet.min11, this.inputs.weight, this.inputs.gear),
};
this.info.state.color = this._calculatorService.getStateColor(this.info.sizesFeet);
this.chartConfig.lineChartData.datasets[0].data = this.sizesMinValuesFeet;
this.chartConfig.lineChartData.datasets[1].data = this.sizesValuesFeet;
this.displayCharts = true;
}
public setCurrentWeight(weight: number) {
this.inputs.weight = weight;
this.refresh();
}
}

Some files were not shown because too many files have changed in this diff Show More