Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69cf4548d2 | |||
| 5a7baf4d94 | |||
| c7d21bda30 | |||
| 3ecc83458f | |||
| d6c864d6d1 | |||
| 223955eb84 | |||
| c32191b37d | |||
| bd8ed94b1d | |||
| 518168f225 | |||
| f9ee9ed799 | |||
| 59361d46a3 | |||
| 9d5d0fca56 | |||
| f5e8c765b7 | |||
| 8a602bd2db | |||
| 43d61fcf16 | |||
| 86032382d6 | |||
| 8a1c19e011 | |||
| 8e3bd16b60 | |||
| 5340e737c6 | |||
| 8b098f7545 | |||
| 65ae9fce4b | |||
| a0a194306f | |||
| 452475b26e | |||
| ed6104ed21 | |||
| 977104aac2 | |||
| 0a6cbc0c00 | |||
| 7dcb426ef5 |
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
APP_ENV="development"
|
||||
API_BASE_URL=http://localhost:3201
|
||||
@@ -1,2 +0,0 @@
|
||||
APP_ENV="production"
|
||||
API_BASE_URL=http://api.adastra-cbd.com/api
|
||||
@@ -1,2 +0,0 @@
|
||||
APP_ENV="development"
|
||||
API_BASE_URL=http://localhost:3200/api
|
||||
@@ -1,14 +1,46 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript", "prettier"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"destructuredArrayIgnorePattern": "^_"
|
||||
}
|
||||
"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"
|
||||
],
|
||||
"rules": {
|
||||
"@angular-eslint/directive-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "attribute",
|
||||
"prefix": "app",
|
||||
"style": "camelCase"
|
||||
}
|
||||
],
|
||||
"@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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,36 +6,10 @@
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# dependencies
|
||||
# Node
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# 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
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
@@ -59,10 +33,10 @@ next-env.d.ts
|
||||
.nx/
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
*.pem
|
||||
|
||||
# e2e
|
||||
/e2e/*.js
|
||||
@@ -80,4 +54,5 @@ sonar.properties
|
||||
**/*.copy.scss
|
||||
**/*.copy.html
|
||||
|
||||
certificates
|
||||
.next/
|
||||
next-env.d.ts
|
||||
@@ -1,3 +0,0 @@
|
||||
npm test
|
||||
#prettier $(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') --write --ignore-unknown
|
||||
#git update-index --again
|
||||
@@ -1,6 +0,0 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
|
||||
# auto generated files
|
||||
src/generated
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1,42 +1,27 @@
|
||||
# AdAstra_App
|
||||
# Ad Astra
|
||||
|
||||
## Libraries and Frameworks
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.2.2.
|
||||
|
||||
| Name | |
|
||||
| --------------------------------------------- | ---------------------------------- |
|
||||
| [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 |
|
||||
## Development server
|
||||
|
||||
## Getting Started
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
|
||||
|
||||
```bash
|
||||
# copy env
|
||||
cp .env.example .env.local
|
||||
## Code scaffolding
|
||||
|
||||
# set API_BASE_URL variable in .env.local to your backend api endpoint
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
|
||||
# run app
|
||||
npm run dev
|
||||
```
|
||||
## Build
|
||||
|
||||
## Directory structure
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
|
||||
```plaintext
|
||||
.
|
||||
├── api/ # api schema
|
||||
├── 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
|
||||
```
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"$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": [
|
||||
"node_modules/chartist/dist/index.scss",
|
||||
"src/styles/styles.scss"
|
||||
],
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": ["src/styles"]
|
||||
},
|
||||
"scripts": [],
|
||||
"browser": "src/main.ts"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"extractLicenses": true,
|
||||
"namedChunks": false,
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kb",
|
||||
"maximumError": "1mb"
|
||||
},
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -1,49 +1,67 @@
|
||||
{
|
||||
"name": "adastra-nextjs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"stackblitz": {
|
||||
"startCommand": "npm start"
|
||||
},
|
||||
"name": "adastra-app",
|
||||
"version": "1.0.0",
|
||||
"author": "Solide Apps <admin@unespace.com>",
|
||||
"contributors": [
|
||||
"Julien Gautier <julien.gautier@unespace.com>"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "next dev --port 4201",
|
||||
"secure": "next dev --experimental-https",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"prepare": "husky",
|
||||
"format": "prettier . --write",
|
||||
"fix": "next lint --fix",
|
||||
"test": "npm run build",
|
||||
"openapi-generate": "openapi-typescript ./api/openapi.yml --output ./src/generated/apiSchema.generated.d.ts"
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"serve": "ng serve --configuration local --port 4201",
|
||||
"dev": "APP_ENV=development ng serve --port 4300 --configuration development",
|
||||
"local": "APP_ENV=local ng serve --port 4400 --configuration local",
|
||||
"lint": "ng lint",
|
||||
"test": "ng test"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.1 || ^20.11.1"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@conform-to/react": "^1.2.2",
|
||||
"@conform-to/zod": "^1.2.2",
|
||||
"clsx": "^2.1.1",
|
||||
"@angular/animations": "^18.2.14",
|
||||
"@angular/cdk": "^18.2.14",
|
||||
"@angular/common": "^18.2.14",
|
||||
"@angular/compiler": "^18.2.14",
|
||||
"@angular/core": "^18.2.14",
|
||||
"@angular/forms": "^18.2.14",
|
||||
"@angular/google-maps": "^17.3.8",
|
||||
"@angular/localize": "^18.2.14",
|
||||
"@angular/material": "^18.2.14",
|
||||
"@angular/platform-browser": "^18.2.14",
|
||||
"@angular/platform-browser-dynamic": "^18.2.14",
|
||||
"@angular/router": "^18.2.14",
|
||||
"bootstrap": "^5.3.7",
|
||||
"next": "^15.4.5",
|
||||
"react": "^19.1.1",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.1.1",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"unified": "^11.0.5",
|
||||
"zod": "^3.23.8"
|
||||
"chart.js": "^4.4.2",
|
||||
"chartist": "^1.3.0",
|
||||
"ng-chartist": "^8.2.0",
|
||||
"ng2-charts": "^6.0.1",
|
||||
"ngx-skeleton-loader": "^9.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "^0.14.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-config-next": "^15.4.5",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"husky": "^9.1.6",
|
||||
"lint-staged": "^15.2.10",
|
||||
"openapi-typescript": "^7.4.1",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.9.2",
|
||||
"sass": "^1.89.2"
|
||||
"@angular-devkit/build-angular": "^18.2.21",
|
||||
"@angular-eslint/builder": "18.4.3",
|
||||
"@angular-eslint/eslint-plugin": "18.4.3",
|
||||
"@angular-eslint/eslint-plugin-template": "18.4.3",
|
||||
"@angular-eslint/schematics": "18.4.3",
|
||||
"@angular-eslint/template-parser": "18.4.3",
|
||||
"@angular/cli": "^18.2.21",
|
||||
"@angular/compiler-cli": "^18.2.14",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.2.0",
|
||||
"@typescript-eslint/parser": "7.2.0",
|
||||
"eslint": "^8.57.0",
|
||||
"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",
|
||||
"typescript": "~5.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -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=" " horiz-adv-x="512" d="" />
|
||||
<glyph unicode="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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="" 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 |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -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 |
@@ -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 +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"}
|
||||
@@ -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 +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 +0,0 @@
|
||||
export * from "./timeClock";
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -0,0 +1 @@
|
||||
<app-full-layout [class]="appClass"></app-full-layout>
|
||||
@@ -0,0 +1,8 @@
|
||||
.grayscale {
|
||||
filter: grayscale(1);
|
||||
transition: all 2s ease;
|
||||
}
|
||||
.colored {
|
||||
filter: grayscale(0);
|
||||
transition: all 5s ease;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have the 'adastra_angular' title`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('adastra_angular');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, adastra_angular');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component, OnInit, AfterContentInit } 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',
|
||||
standalone: true,
|
||||
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 {
|
||||
public title = 'Ad Astra';
|
||||
public appClass = 'grayscale';
|
||||
|
||||
constructor(private userService: UserService, private titleService: Title) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.userService.populate();
|
||||
this.titleService.setTitle(this.title);
|
||||
}
|
||||
|
||||
ngAfterContentInit() {
|
||||
this.appClass = 'colored';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
<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";
|
||||
@@ -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;
|
||||
@@ -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,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AeronefsComponent } from './aeronefs.component';
|
||||
|
||||
describe('AeronefsComponent', () => {
|
||||
let component: AeronefsComponent;
|
||||
let fixture: ComponentFixture<AeronefsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AeronefsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AeronefsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
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',
|
||||
standalone: true,
|
||||
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 _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();
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private _utilitiesService: UtilitiesService,
|
||||
public menuItems: MenuItems
|
||||
) { }
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthComponent } from './auth.component';
|
||||
|
||||
describe('AuthComponent', () => {
|
||||
let component: AuthComponent;
|
||||
let fixture: ComponentFixture<AuthComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AuthComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(AuthComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
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, ShowAuthedDirective } from '@components/shared';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
ListErrorsComponent,
|
||||
ShowAuthedDirective
|
||||
],
|
||||
selector: 'app-auth',
|
||||
templateUrl: './auth.component.html',
|
||||
styleUrl: './auth.component.scss'
|
||||
})
|
||||
export class AuthComponent implements OnInit, OnDestroy {
|
||||
private _url: Subscription = new Subscription();
|
||||
private _user: Subscription = new Subscription();
|
||||
authType = '';
|
||||
title = '';
|
||||
btnTitle = '';
|
||||
errors: Errors = { errors: {} };
|
||||
isSubmitting = false;
|
||||
authForm!: UntypedFormGroup;
|
||||
destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private titleService: Title,
|
||||
private userService: UserService,
|
||||
private fb: UntypedFormBuilder
|
||||
) {
|
||||
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,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CalculatorComponent } from './calculator.component';
|
||||
|
||||
describe('CalculatorComponent', () => {
|
||||
let component: CalculatorComponent;
|
||||
let fixture: ComponentFixture<CalculatorComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CalculatorComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(CalculatorComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Component, Input, OnInit, OnDestroy, ViewChild } 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',
|
||||
standalone: true,
|
||||
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 _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;
|
||||
|
||||
constructor(
|
||||
private _calculatorService: CalculatorService,
|
||||
private _jumpsService: JumpsService,
|
||||
private _userService: UserService,
|
||||
private _utilitiesService: UtilitiesService,
|
||||
public menuItems: MenuItems
|
||||
) { }
|
||||
|
||||
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.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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<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]="menuCanopyModel" aria-label="Menu Canopy Model">
|
||||
<mat-icon fontIcon="more_vert"></mat-icon>
|
||||
</button>
|
||||
<mat-menu #menuCanopyModel="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-3 col-lg-6 col-md-6 col-sm-12">
|
||||
<app-pie-chart [headers]="seriesModelHeader" [names]="seriesModelName" [values]="seriesModelValue" [colors]="seriesColor"></app-pie-chart>
|
||||
</div>
|
||||
<div class="col-xl-3 col-lg-6 col-md-6 col-sm-12">
|
||||
<app-pie-chart [headers]="seriesSizeHeader" [names]="seriesSizeName" [values]="seriesSizeValue" [colors]="seriesColor"></app-pie-chart>
|
||||
</div>
|
||||
<div class="col-xl-6 col-lg-12 col-md-12">
|
||||
<!--<app-pie-chart [headers]="seriesSizeHeader" [names]="seriesSizeName" [values]="seriesSizeValue" [colors]="seriesColor"></app-pie-chart>-->
|
||||
<app-bars-chart [headers]="seriesSizeHeader" [names]="seriesSizeName" [values]="seriesSizeRow" [colors]="seriesColor" [legend]="false"></app-bars-chart>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="row">
|
||||
<div class="col-xl-6 col-lg-12">
|
||||
<mat-divider class="mt-3 mb-0"></mat-divider>
|
||||
<app-history-table [headers]="seriesModelHeader" [names]="seriesModelName" [values]="seriesModelValue" [rows]="seriesModelRow" [colors]="seriesColorClass"></app-history-table>
|
||||
</div>
|
||||
<div class="col-xl-6 col-lg-12">
|
||||
<mat-divider class="mt-3 mb-0"></mat-divider>
|
||||
<app-history-table [headers]="seriesSizeHeader" [names]="seriesSizeName" [values]="seriesSizeValue" [rows]="seriesSizeRow" [colors]="seriesColorClass"></app-history-table>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CanopiesComponent } from './canopies.component';
|
||||
|
||||
describe('CanopiesComponent', () => {
|
||||
let component: CanopiesComponent;
|
||||
let fixture: ComponentFixture<CanopiesComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CanopiesComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CanopiesComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
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 { 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, CanopiesPageData } from '@models';
|
||||
import { UtilitiesService } from '@services';
|
||||
import { CanopyBySize, CanopyByYear, CanopyModelBySize, CanopyModelByYear } from '@models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-canopies',
|
||||
standalone: true,
|
||||
imports: [
|
||||
DatePipe,
|
||||
MatButtonModule, MatCardModule, MatDividerModule, MatExpansionModule,
|
||||
MatGridListModule, MatIconModule, MatMenuModule,
|
||||
HistoryTableComponent, BarsChartComponent, PieChartComponent
|
||||
],
|
||||
templateUrl: './canopies.component.html',
|
||||
styleUrl: './canopies.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 CanopiesComponent implements OnInit, OnDestroy {
|
||||
private _data: Subscription = new Subscription();
|
||||
private _canopiesModelBySize!: Array<CanopyModelBySize>;
|
||||
private _canopiesModelByYear!: Array<CanopyModelByYear>;
|
||||
private _canopiesSizeBySize!: Array<CanopyBySize>;
|
||||
private _canopiesSizeByYear!: Array<CanopyByYear>;
|
||||
public lastJump: Jump = {} as Jump;
|
||||
public title = 'Voiles';
|
||||
public subtitle: string = 'Nombre total de sauts par modèle et taille';
|
||||
public displayCharts = false;
|
||||
public destroyRef = inject(DestroyRef);
|
||||
public seriesModelHeader: string[] = [];
|
||||
public seriesModelName: string[] = [];
|
||||
public seriesModelValue: number[] = [];
|
||||
public seriesModelRow: Array<Array<number>> = [];
|
||||
public seriesSizeHeader: string[] = [];
|
||||
public seriesSizeName: string[] = [];
|
||||
public seriesSizeValue: number[] = [];
|
||||
public seriesSizeRow: 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();
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private _utilitiesService: UtilitiesService,
|
||||
public menuItems: MenuItems
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
const data$: Observable<{ canopiesPageData: CanopiesPageData }> = this.route.data as Observable<{ canopiesPageData: CanopiesPageData }>;
|
||||
this._data = data$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data: { canopiesPageData: CanopiesPageData }) => {
|
||||
const pageData: CanopiesPageData = data.canopiesPageData;
|
||||
this.lastJump = pageData.lastjump;
|
||||
this._loadCanopyBySize(pageData);
|
||||
this._loadCanopyModelBySize(pageData);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._data.unsubscribe();
|
||||
}
|
||||
|
||||
private _loadCanopyBySize(pageData: CanopiesPageData): void {
|
||||
this._canopiesSizeBySize = pageData.canopiesBySize.map((row: CanopyBySize) => {
|
||||
this.seriesSizeName.push(row.taille.toString());
|
||||
this.seriesSizeValue.push(row.count);
|
||||
return row;
|
||||
});
|
||||
this._loadCanopyByYear(pageData);
|
||||
}
|
||||
|
||||
private _loadCanopyByYear(pageData: CanopiesPageData): void {
|
||||
this.seriesSizeHeader = ['2018', '2019', '2020', '2021', '2022', '2023', '2024'];
|
||||
const values: Array<number> = this.seriesSizeHeader.map(() => 0);
|
||||
this.seriesSizeName.forEach(() => {
|
||||
this.seriesSizeRow.push([...values]);
|
||||
});
|
||||
this._canopiesSizeByYear = pageData.canopiesBySizeByYear.map((row: CanopyByYear) => {
|
||||
const taille: string = row.taille.toString();
|
||||
const year: string = row.year.toString();
|
||||
this.seriesSizeRow[this.seriesSizeName.indexOf(taille)][this.seriesSizeHeader.indexOf(year)] = row.count;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
private _loadCanopyModelBySize(pageData: CanopiesPageData): void {
|
||||
this._canopiesModelBySize = pageData.canopiesBySizeByModel.map((row: CanopyModelBySize) => {
|
||||
this.seriesModelName.push(`${row.taille.toString()} - ${row.voile}`);
|
||||
this.seriesModelValue.push(row.count);
|
||||
return row;
|
||||
});
|
||||
this._loadCanopyModelByYear(pageData);
|
||||
}
|
||||
|
||||
private _loadCanopyModelByYear(pageData: CanopiesPageData): void {
|
||||
this.seriesModelHeader = ['2018', '2019', '2020', '2021', '2022', '2023', '2024'];
|
||||
const values: Array<number> = this.seriesModelHeader.map(() => 0);
|
||||
this.seriesModelName.forEach(() => {
|
||||
this.seriesModelRow.push([...values]);
|
||||
});
|
||||
this._canopiesModelByYear = pageData.canopiesBySizeByModelByYear.map((row: CanopyModelByYear) => {
|
||||
const voile: string = row.voile;
|
||||
const taille: string = row.taille.toString();
|
||||
const year: string = row.year.toString();
|
||||
this.seriesModelRow[this.seriesModelName.indexOf(`${taille} - ${voile}`)][this.seriesModelHeader.indexOf(year)] = row.count;
|
||||
return row;
|
||||
});
|
||||
this.displayCharts = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<h1 class="mb-1">{{title}}</h1>
|
||||
<span [hidden]="!lastJump.numero" class="me-2 fst-italic0 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>
|
||||
<mat-divider class="my-3"></mat-divider>
|
||||
<ng-container *appShowAuthed="true">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<app-jumps-by-month></app-jumps-by-month>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-sm-2 mt-lg-3">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<app-dropzones-pie></app-dropzones-pie>
|
||||
</div>
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<app-dropzones-bar></app-dropzones-bar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-sm-2 mt-lg-3">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<app-aeronefs-pie></app-aeronefs-pie>
|
||||
</div>
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<app-aeronefs-bar></app-aeronefs-bar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-sm-2 mt-lg-3">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<app-canopies-models></app-canopies-models>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<app-canopies-sizes></app-canopies-sizes>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DashboardComponent } from './dashboard.component';
|
||||
|
||||
describe('DashboardComponent', () => {
|
||||
let component: DashboardComponent;
|
||||
let fixture: ComponentFixture<DashboardComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [DashboardComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(DashboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { ActivatedRoute, Data, Router } from '@angular/router';
|
||||
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
import { Aeronef, AeronefByImat, Canopy, CanopyModelBySize, DropZone, DropZoneByYear, Errors, Jump, User } from '@models';
|
||||
//import { AeronefsService, CanopiesService, DropZonesService, UserService } from '@services';
|
||||
import { JumpsService, UserService } from '@services';
|
||||
import { ShowAuthedDirective } from '@components/shared/show-authed.directive';
|
||||
import {
|
||||
AeronefsPieComponent, AeronefsBarComponent,
|
||||
CanopiesModelsComponent, CanopiesSizesComponent,
|
||||
DropzonesPieComponent, DropzonesBarComponent,
|
||||
JumpsByMonthComponent
|
||||
} from '@components/shared/dashboard-components';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
DatePipe, ShowAuthedDirective, MatDividerModule,
|
||||
AeronefsPieComponent, AeronefsBarComponent,
|
||||
CanopiesModelsComponent, CanopiesSizesComponent,
|
||||
DropzonesPieComponent, DropzonesBarComponent,
|
||||
JumpsByMonthComponent
|
||||
],
|
||||
selector: 'app-dashboard',
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrl: './dashboard.component.scss'
|
||||
})
|
||||
export class DashboardComponent implements OnInit, OnDestroy {
|
||||
private _currentUser: Subscription = new Subscription();
|
||||
private _data: Subscription = new Subscription();
|
||||
private _lastjump: Subscription = new Subscription();
|
||||
private _lastjump$: Observable<Jump> = new Observable();
|
||||
/*
|
||||
private _aeronefs: Subscription = new Subscription();
|
||||
private _canopies: Subscription = new Subscription();
|
||||
private _dropzones: Subscription = new Subscription();
|
||||
*/
|
||||
public title = 'Dashboard';
|
||||
public errors!: Errors;
|
||||
public isAuthenticated = false;
|
||||
public canModify = false;
|
||||
public aeronefAggregate!: Array<AeronefByImat>;
|
||||
public aeronefs!: Array<Aeronef>;
|
||||
public aeronefsCount = 0;
|
||||
public canopyAggregate!: Array<CanopyModelBySize>;
|
||||
public canopies!: Array<Canopy>;
|
||||
public canopiesCount = 0;
|
||||
public dropzoneAggregate!: Array<DropZoneByYear>;
|
||||
public dropzones!: Array<DropZone>;
|
||||
public dropzonesCount = 0;
|
||||
public lastJump: Jump = {} as Jump;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private titleService: Title,
|
||||
private _userService: UserService,
|
||||
private _jumpsService: JumpsService
|
||||
/*
|
||||
private aeronefsService: AeronefsService,
|
||||
private canopiesService: CanopiesService,
|
||||
private dropzonesService: DropZonesService
|
||||
*/
|
||||
) {
|
||||
this._resetErrors();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.titleService.setTitle(this.title);
|
||||
const data$: Observable<Data> = this.route.data.pipe(take(1));
|
||||
this._data = data$.subscribe((data: Data) => {
|
||||
this.isAuthenticated = data['isAuthenticated'];
|
||||
if (!this.isAuthenticated) {
|
||||
this.router.navigateByUrl('/login');
|
||||
return;
|
||||
}
|
||||
const currentUser$: Observable<User> = this._userService.currentUser;
|
||||
this._currentUser = currentUser$.subscribe((userData: User) => {
|
||||
this.canModify = userData.role === 'Admin';
|
||||
});
|
||||
|
||||
this._lastjump$ = this._jumpsService.getLastJump();
|
||||
this._lastjump = this._lastjump$.subscribe((jump) => {
|
||||
this.lastJump = jump;
|
||||
});
|
||||
});
|
||||
/*
|
||||
this._loadAeronefs();
|
||||
this._loadCanopies();
|
||||
this._loadDropzones();
|
||||
*/
|
||||
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._currentUser.unsubscribe();
|
||||
this._data.unsubscribe();
|
||||
this._lastjump.unsubscribe();
|
||||
/*
|
||||
this._aeronefs.unsubscribe();
|
||||
this._canopies.unsubscribe();
|
||||
this._dropzones.unsubscribe();
|
||||
*/
|
||||
}
|
||||
|
||||
private _resetErrors(): void {
|
||||
this.errors = { errors: {} };
|
||||
}
|
||||
|
||||
/*
|
||||
private _loadAeronefs(): void {
|
||||
const aeronefs$: Observable<Array<AeronefByImat>> = this.aeronefsService.getAllByImat();
|
||||
this._aeronefs = aeronefs$.subscribe((aggregate: Array<AeronefByImat>) => {
|
||||
this.aeronefAggregate = aggregate;
|
||||
this.aeronefsCount = aggregate.length;
|
||||
console.log(this.aeronefAggregate);
|
||||
console.log(this.aeronefsCount);
|
||||
});
|
||||
}
|
||||
|
||||
private _loadCanopies(): void {
|
||||
const canopies$: Observable<Array<CanopyModelBySize>> = this.canopiesService.getAllModelBySize();
|
||||
this._canopies = canopies$.subscribe((aggregate: Array<CanopyModelBySize>) => {
|
||||
this.canopyAggregate = aggregate;
|
||||
this.canopiesCount = aggregate.length;
|
||||
console.log(this.canopyAggregate);
|
||||
console.log(this.canopiesCount);
|
||||
});
|
||||
}
|
||||
|
||||
private _loadDropzones(): void {
|
||||
const dropzones$: Observable<Array<DropZoneByYear>> = this.dropzonesService.getAllByDate();
|
||||
this._dropzones = dropzones$.subscribe((aggregate: Array<DropZoneByYear>) => {
|
||||
this.dropzoneAggregate = aggregate;
|
||||
this.dropzonesCount = aggregate.length;
|
||||
console.log(this.dropzoneAggregate);
|
||||
console.log(this.dropzonesCount);
|
||||
});
|
||||
}
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
<div class="banner mat-elevation-z2">
|
||||
<div class="banner-content d-flex text-bg-accent bg-opacity-50">
|
||||
<img [src]="user.image" class="user-img me-3" [alt]="user.username" />
|
||||
<div class="flex-spacer">
|
||||
<h3 class="mb-0">{{bannerTitle}}</h3>
|
||||
<span class="fs-6">{{user.firstname}} {{user.lastname}}</span>
|
||||
</div>
|
||||
<div class="align-self-center text-end">
|
||||
<button mat-raised-button color="warn" routerLink="/settings">
|
||||
<mat-icon aria-label="Modifier le compte" fontIcon="settings"></mat-icon> {{ btnSettingsTitle }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="demo-page">
|
||||
<div class="d-flex">
|
||||
<div class="flex-fill">
|
||||
<h1 class="mb-1">{{title}}</h1>
|
||||
</div>
|
||||
<span class="flex-spacer"></span>
|
||||
<div class="align-self-center me-2 text-end"></div>
|
||||
</div>
|
||||
<mat-divider class="my-3"></mat-divider>
|
||||
<app-list-errors [errors]="errors"></app-list-errors>
|
||||
</div>
|
||||
<div class="content mt-3">
|
||||
<mat-card>
|
||||
<mat-card-header class="d-flex text-bg-primary rounded-top py-2 px-3">
|
||||
<div class="flex-fill">
|
||||
<mat-card-title>
|
||||
Buttons variants
|
||||
</mat-card-title>
|
||||
<mat-card-subtitle class="fw-normal text-white">
|
||||
{{ getColors().length }} colors
|
||||
</mat-card-subtitle>
|
||||
</div>
|
||||
<span class="flex-spacer"></span>
|
||||
<div class="align-self-center">
|
||||
<button mat-icon-button>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<section class="mt-4">
|
||||
<div class="mb-2">Links</div>
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
@for (color of getColors(); track color) {
|
||||
<div class="m-2">
|
||||
<a href="#" class="text-{{ color }}">{{ color }}</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
@for (color of getColors(); track color) {
|
||||
<div class="m-2">
|
||||
<span class="text-{{ color }}">{{ color }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<mat-divider class="my-4"></mat-divider>
|
||||
<section>
|
||||
<div class="mb-2">Normal</div>
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
@for (color of getColors(); track color) {
|
||||
<div class="m-2">
|
||||
<button mat-button class="text-nowrap" [color]="color">{{ color }}</button>
|
||||
</div>
|
||||
}
|
||||
<div class="m-2">
|
||||
<button mat-button>Basic</button>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<button mat-button disabled>Disabled</button>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<a mat-button href="https://www.google.com/" target="_blank"><a Link</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<mat-divider class="my-4"></mat-divider>
|
||||
<section>
|
||||
<div class="mb-2">Stroked</div>
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
@for (color of getColors(); track color) {
|
||||
<div class="m-2">
|
||||
<button mat-stroked-button class="text-nowrap" [color]="color">{{ color }}</button>
|
||||
</div>
|
||||
}
|
||||
<div class="m-2">
|
||||
<button mat-stroked-button>Basic</button>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<button mat-stroked-button disabled>Disabled</button>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<a mat-stroked-button href="https://www.google.com/" target="_blank"><a Link</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<mat-divider class="my-4"></mat-divider>
|
||||
<section>
|
||||
<div class="mb-2">Flat</div>
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
@for (color of getColors(); track color) {
|
||||
<div class="m-2">
|
||||
<button mat-flat-button class="text-nowrap" [color]="color">{{ color }}</button>
|
||||
</div>
|
||||
}
|
||||
<div class="m-2">
|
||||
<button mat-flat-button>Basic</button>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<button mat-flat-button disabled>Disabled</button>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<a mat-flat-button href="https://www.google.com/" target="_blank"><a Link</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<mat-divider class="my-4"></mat-divider>
|
||||
<section>
|
||||
<div class="mb-2">Raised</div>
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
@for (color of getColors(); track color) {
|
||||
<div class="m-2">
|
||||
<button mat-raised-button class="text-nowrap" [color]="color">{{ color }}</button>
|
||||
</div>
|
||||
}
|
||||
<div class="m-2">
|
||||
<button mat-raised-button>Basic</button>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<button mat-raised-button disabled>Disabled</button>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<a mat-raised-button href="https://www.google.com/" target="_blank"><a Link</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<mat-divider class="my-4"></mat-divider>
|
||||
<section>
|
||||
<div class="mb-2">FAB</div>
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
@for (color of getColors(); track color; let index = $index) {
|
||||
<div class="m-2">
|
||||
<button mat-fab [color]="color" aria-label="Example icon button with an icon">
|
||||
<mat-icon>{{ getIcon(index) }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="m-2">
|
||||
<button mat-fab color="white" aria-label="Example icon button with a vertical three dot icon">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<button mat-fab disabled aria-label="Example icon button with a block icon">
|
||||
<mat-icon>block</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<mat-divider class="my-4"></mat-divider>
|
||||
<section>
|
||||
<div class="mb-2">FAB extended</div>
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
@for (color of getColors(); track color; let index = $index) {
|
||||
<div class="m-2">
|
||||
<button mat-fab extended [color]="color" aria-label="Example icon button with an icon">
|
||||
<mat-icon>{{ getIcon(index) }}</mat-icon>
|
||||
{{ color }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="m-2">
|
||||
<button mat-fab extended color="white" aria-label="Example icon button with a vertical three dot icon">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
white
|
||||
</button>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<button mat-fab extended disabled aria-label="Example icon button with a block icon">
|
||||
<mat-icon>block</mat-icon>
|
||||
disabled
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<mat-divider class="my-4"></mat-divider>
|
||||
<section>
|
||||
<div class="mb-2">Mini FAB</div>
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
@for (color of getColors(); track color; let index = $index) {
|
||||
<div class="m-2">
|
||||
<button mat-mini-fab [color]="color" aria-label="Example icon button with an icon">
|
||||
<mat-icon>{{ getIcon(index) }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="m-2">
|
||||
<button mat-mini-fab color="white" aria-label="Example icon button with a vertical three dot icon">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<button mat-mini-fab disabled aria-label="Example icon button with a block icon">
|
||||
<mat-icon>block</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<mat-divider class="my-4"></mat-divider>
|
||||
<section>
|
||||
<div class="mb-2">Icon</div>
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
@for (color of getColors(); track color; let index = $index) {
|
||||
<div class="m-2">
|
||||
<button mat-icon-button [color]="color" aria-label="Example icon button with an icon">
|
||||
<mat-icon>{{ getIcon(index) }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="m-2">
|
||||
<button mat-icon-button aria-label="Example icon button with a vertical three dot icon">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<button mat-icon-button disabled aria-label="Example icon button with a open in new tab icon">
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<mat-divider class="my-4"></mat-divider>
|
||||
<div cdkDropList class="row mb-3" (cdkDropListDropped)="drop($event)">
|
||||
@for (color of getColors(); track color) {
|
||||
<div class="col-xs-12 col-lg-6 col-xxl-4 pb-3" cdkDrag>
|
||||
<div class="card-custom-placeholder col-xs-12 col-lg-6 col-xxl-4 pb-3" *cdkDragPlaceholder></div>
|
||||
<mat-card>
|
||||
<mat-card-header class="d-flex text-bg-{{ color }} rounded-top py-2 px-3">
|
||||
<div class="flex-fill">
|
||||
<mat-card-title>
|
||||
{{ color }}
|
||||
</mat-card-title>
|
||||
<mat-card-subtitle class="fw-normal">
|
||||
Card subtitle
|
||||
</mat-card-subtitle>
|
||||
</div>
|
||||
<span class="flex-spacer"></span>
|
||||
<div class="align-self-center">
|
||||
<button mat-icon-button>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<section class="mt-4 mb-3">
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
<button mat-raised-button [color]="color" class="m-2 align-self-center">Texte Raised</button>
|
||||
<button mat-flat-button [color]="color" class="m-2 align-self-center">Texte Flat</button>
|
||||
<button mat-stroked-button [color]="color" class="m-2 align-self-center">Texte Stroked</button>
|
||||
<button mat-button [color]="color" class="m-2 align-self-center">{{ color }}</button>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-start mb-3">
|
||||
<button mat-fab extended [color]="color" class="m-2 align-self-center">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
{{ color }}
|
||||
</button>
|
||||
<button mat-fab [color]="color" class="m-2 align-self-center">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
</button>
|
||||
<button mat-mini-fab [color]="color" class="m-2 align-self-center">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button [color]="color" class="m-2 align-self-center">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<mat-progress-bar [color]="color" mode="determinate" value="40"></mat-progress-bar>
|
||||
<mat-slider class="w-100" min="0" max="1000">
|
||||
<input value="300" matSliderStartThumb>
|
||||
<input value="600" matSliderEndThumb>
|
||||
</mat-slider>
|
||||
</section>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
/* Demo */
|
||||
@use 'variable' as var;
|
||||
|
||||
.mat-mdc-card + .mat-mdc-card {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
width: 500px;
|
||||
max-width: 100%;
|
||||
border: solid 1px #ccc;
|
||||
min-height: 60px;
|
||||
display: block;
|
||||
background: var.$white;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-box {
|
||||
padding: 20px 10px;
|
||||
border-bottom: solid 1px #ccc;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
cursor: move;
|
||||
background: var.$white;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cdk-drag-preview {
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
|
||||
0 8px 10px 1px rgba(0, 0, 0, 0.14),
|
||||
0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card-box:last-child {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.card-list.cdk-drop-list-dragging .card-box:not(.cdk-drag-placeholder) {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
.card-custom-placeholder {
|
||||
background: #ccc;
|
||||
border: dotted 3px #999;
|
||||
min-height: 60px;
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DemoComponent } from './demo.component';
|
||||
|
||||
describe('DemoComponent', () => {
|
||||
let component: DemoComponent;
|
||||
let fixture: ComponentFixture<DemoComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DemoComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DemoComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import {CdkDragDrop, CdkDropList, CdkDrag, CdkDragPlaceholder, moveItemInArray} from '@angular/cdk/drag-drop';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSliderModule } from '@angular/material/slider';
|
||||
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
import { Errors, User } from '@models';
|
||||
import { UserService } from '@services';
|
||||
import { ListErrorsComponent } from '@components/shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-demo',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CdkDropList, CdkDrag, CdkDragPlaceholder, RouterLink,
|
||||
MatButtonModule, MatCardModule, MatDividerModule,
|
||||
MatIconModule, MatProgressBarModule, MatSliderModule,
|
||||
ListErrorsComponent
|
||||
],
|
||||
templateUrl: './demo.component.html',
|
||||
styleUrl: './demo.component.scss'
|
||||
})
|
||||
export class DemoComponent implements OnInit, OnDestroy {
|
||||
private _user: Subscription = new Subscription();
|
||||
private _colors: string[] = [
|
||||
"primary", "accent", "warn", "secondary", "megna", "raspberry", "success", "info",
|
||||
"warning", "danger", "red", "orange", "yellow", "green", "teal", "turquoise", "cyan",
|
||||
"blue", "navy", "navy-light", "pink", "magenta", "purple", "purple-light", "grey", "muted"
|
||||
];
|
||||
private _icons: string[] = [
|
||||
'mode_edit', 'delete', 'bookmark', 'settings', 'home', 'person',
|
||||
'share', 'account_circle', 'public', 'search', 'favorite', 'done',
|
||||
'numbers', 'attach_file', 'insert_comment', 'format_list_bulleted',
|
||||
'filter', 'crop', 'edit_attributes', 'upload', 'download', 'expand_less',
|
||||
'expand_more', 'chevron_left', 'chevron_right', 'sync', 'power',
|
||||
'power_off', 'notifications', 'emoji_emotions', 'person_add', 'block'
|
||||
];
|
||||
|
||||
title = 'Demo';
|
||||
bannerTitle = '';
|
||||
user: Partial<User> = {} as Partial<User>;
|
||||
errors: Errors = { errors: {} };
|
||||
btnSettingsTitle = 'Paramètres';
|
||||
|
||||
constructor(
|
||||
private titleService: Title,
|
||||
private userService: UserService
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.titleService.setTitle(`Ad Astra - ${this.title}`);
|
||||
Object.assign(this.user, this.userService.getCurrentUser());
|
||||
if (this.user.username) {
|
||||
this.bannerTitle = `@${this.user.username}`;
|
||||
} else {
|
||||
this.bannerTitle = `${this.user.firstname} ${this.user.lastname}`;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._user.unsubscribe();
|
||||
}
|
||||
|
||||
getColors(): string[] {
|
||||
return this._colors;
|
||||
}
|
||||
|
||||
getIcon(index: number): string {
|
||||
return this._icons[index];
|
||||
}
|
||||
|
||||
drop(event: CdkDragDrop<string[]>) {
|
||||
moveItemInArray(this._colors, event.previousIndex, event.currentIndex);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<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]="menuDropzone" aria-label="Menu Dropzone">
|
||||
<mat-icon fontIcon="more_vert"></mat-icon>
|
||||
</button>
|
||||
<mat-menu #menuDropzone="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-bars-chart [headers]="seriesHeader" [names]="seriesName" [values]="seriesRow" [colors]="seriesColor" [legend]="false"></app-bars-chart>
|
||||
<!--
|
||||
@if (displayCharts) {
|
||||
<div class="barchart position-relative w-100 my-1">
|
||||
<x-chartist [configuration]="chartBarConfig"></x-chartist>
|
||||
</div>
|
||||
}
|
||||
-->
|
||||
</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>
|
||||
<mat-divider class="my-3"></mat-divider>
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>Carte des DZ</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content class="mt-4">
|
||||
<div class="ratio ratio-21x9">
|
||||
<google-map height="100%" width="100%" [center]="center" [zoom]="zoom" [options]="options">
|
||||
<map-kml-layer [url]="kmlUrl" />
|
||||
</google-map>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||