Compare commits
12 Commits
e12bbf29ae
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f82633dd9 | |||
| cd0a6c16aa | |||
| 76c97352bc | |||
| fa00719a3e | |||
| 86c075db10 | |||
| 9ad8129321 | |||
| c8e2fba13e | |||
| 8f2632f456 | |||
| c575992d64 | |||
| 7215c01741 | |||
| dd8fdeb5ca | |||
| 9be9f191bb |
@@ -10,7 +10,8 @@
|
||||
"Bash(npm list *)",
|
||||
"Bash(ng update *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(nvm ls *)"
|
||||
"Bash(nvm ls *)",
|
||||
"Bash(grep -E \"\\\\.\\(js|ts\\)$\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -54,5 +54,11 @@ sonar.properties
|
||||
**/*.copy.scss
|
||||
**/*.copy.html
|
||||
|
||||
# Next.js (legacy, removed)
|
||||
.next/
|
||||
next-env.d.ts
|
||||
next.config.js
|
||||
|
||||
# Environment files (Angular uses src/environments/*.ts)
|
||||
.env
|
||||
.env.*
|
||||
@@ -1,27 +1,97 @@
|
||||
# Ad Astra
|
||||
# AdAstra App
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.2.2.
|
||||
Frontend of the AdAstra platform — a multi-domain Angular application covering e-commerce, CMS, skydive club management, and Hero Wars guild analytics.
|
||||
|
||||
## Development server
|
||||
## Tech stack
|
||||
|
||||
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.
|
||||
| Layer | Choice |
|
||||
| --------- | ------------------------------------------------------------------ |
|
||||
| Framework | Angular 21 (standalone components, no NgModules) |
|
||||
| Language | TypeScript 5.9 — strict mode + strictTemplates |
|
||||
| UI | Angular Material 21, Bootstrap 5 |
|
||||
| Charts | Chart.js 4 (ng2-charts), Chartist |
|
||||
| HTTP | Angular HttpClient with three interceptors (base URL, JWT, errors) |
|
||||
| Tests | Karma + Jasmine |
|
||||
| Linting | angular-eslint + Prettier (enforced via lint-staged) |
|
||||
| Node | `^20.19.0 \|\| ^22.12.0` |
|
||||
|
||||
## Code scaffolding
|
||||
## Related repository
|
||||
|
||||
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`.
|
||||
The backend API lives in [`adastra_api`](../adastra_api) — an Express + Sequelize stack, routes grouped under `/api/ecommerce`, `/api/cms`, `/api/skydive`, `/api/herowars`.
|
||||
|
||||
## Build
|
||||
## Getting started
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
```bash
|
||||
npm install
|
||||
npm run local # dev server on :4400, uses environment.local.ts
|
||||
```
|
||||
|
||||
## Running unit tests
|
||||
Copy and configure your local environment:
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
```bash
|
||||
cp src/environments/environment.example.ts src/environments/environment.local.ts
|
||||
# fill in: apiBaseUrl, apiKey, googleMapApiKey, …
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
## Commands
|
||||
|
||||
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.
|
||||
| Command | Description |
|
||||
| --------------- | ----------------------------------------------------- |
|
||||
| `npm start` | Dev server on :4200 with production config |
|
||||
| `npm run dev` | Dev server on :4300 with `environment.development.ts` |
|
||||
| `npm run local` | Dev server on :4400 with `environment.local.ts` |
|
||||
| `npm run build` | Production build → `dist/adastra_angular/` |
|
||||
| `npm run watch` | Incremental build in development configuration |
|
||||
| `npm run lint` | angular-eslint on `**/*.ts` and `**/*.html` |
|
||||
| `npm test` | Karma + Jasmine unit tests |
|
||||
|
||||
## Further help
|
||||
Run a single spec:
|
||||
|
||||
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.
|
||||
```bash
|
||||
ng test --include src/path/to/file.spec.ts
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Bootstrap
|
||||
|
||||
`AppComponent` renders a single `FullComponent` shell (Material sidenav + toolbar). Routes are split into two arrays in `app.routes.ts`:
|
||||
|
||||
- `auth.routes.ts` — protected by `authGuard`; admin sections additionally require `adminGuard`
|
||||
- `noauth.routes.ts` — public pages and auth entry points, blocked by `noauthGuard` when already authenticated
|
||||
|
||||
Data is pre-fetched by resolvers under `src/app/core/resolvers/` — new route components should follow this pattern rather than fetching in `ngOnInit`.
|
||||
|
||||
### HTTP pipeline
|
||||
|
||||
Three interceptors applied in order:
|
||||
|
||||
1. **`apiInterceptor`** — prepends `environment.apiBaseUrl` to every request. Services use relative paths (`/cms/user`, `/skydive/jumps`, …), never absolute URLs.
|
||||
2. **`tokenInterceptor`** — attaches `Authorization: Token <jwt>` when a token exists in `localStorage['jwtToken']`.
|
||||
3. **`errorInterceptor`** — unwraps `err.error` and rethrows.
|
||||
|
||||
### Feature domains
|
||||
|
||||
| Domain | Routes | Backend prefix |
|
||||
| ---------- | ------------------------------------------------------ | ---------------- |
|
||||
| E-commerce | `products`, `product/:slug`, `products/:category` | `/api/ecommerce` |
|
||||
| CMS | `pages`, `page/:slug`, `home`, profiles, articles | `/api/cms` |
|
||||
| Skydive | `skydive/*` — aeronefs, canopies, dropzones, jumps | `/api/skydive` |
|
||||
| Hero Wars | Guild analytics, weekly snapshots in `src/files-data/` | `/api/herowars` |
|
||||
|
||||
### Path aliases
|
||||
|
||||
Defined in `tsconfig.json`; always prefer them over relative imports:
|
||||
|
||||
```
|
||||
@components @services @models @guards
|
||||
@interceptors @resolvers @viewmodels @interfaces
|
||||
@constants @core/* @environments/*
|
||||
@data/* @assets/* @styles/*
|
||||
```
|
||||
|
||||
When adding a new file, export it from the appropriate `index.ts` barrel so the alias resolves correctly.
|
||||
|
||||
### Styling
|
||||
|
||||
SCSS with `includePaths: ["src/styles"]` — imports like `@use 'variables'` resolve globally. Material date locale is set to `fr-FR` with `DD/MM/YYYY` format.
|
||||
|
||||
@@ -44,7 +44,10 @@
|
||||
],
|
||||
"styles": ["src/styles/styles.scss"],
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": ["src/styles"]
|
||||
"includePaths": ["src/styles"],
|
||||
"sass": {
|
||||
"silenceDeprecations": ["import"]
|
||||
}
|
||||
},
|
||||
"scripts": [],
|
||||
"browser": "src/main.ts"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Migrate frontend from Next.js to Angular
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The project was initially built with Next.js (React). The development team already maintained an existing Angular stack for other projects, creating a knowledge split between two frontend frameworks. Server-side rendering — Next.js's main differentiator — was not required: all data is user-specific and served via authenticated API calls.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Keep Next.js/React
|
||||
- Migrate to Angular
|
||||
- Migrate to Vue or Svelte
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Migrate to Angular", because the team already maintained an Angular stack for other projects, making it the path of least resistance with no additional knowledge split.
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- Single frontend framework across projects — reuse of existing expertise, libraries, and patterns.
|
||||
- No SSR complexity; the app is a pure SPA, which fits Angular's model naturally.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Complete rewrite — no incremental migration path exists between React and Angular.
|
||||
- Next.js artifacts (`.next/`, `src/modules/`, `src/middleware.ts`, etc.) required a dedicated cleanup pass after the migration.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Keep Next.js/React
|
||||
|
||||
- Good, because no rewrite cost.
|
||||
- Bad, because maintaining two frontend frameworks in parallel increases overhead.
|
||||
- Bad, because SSR provides no benefit for this authenticated, user-specific application.
|
||||
|
||||
### Migrate to Angular
|
||||
|
||||
- Good, because leverages existing team expertise.
|
||||
- Good, because consistent toolchain across projects.
|
||||
- Bad, because full rewrite required.
|
||||
|
||||
### Migrate to Vue or Svelte
|
||||
|
||||
- Good, because smaller bundle size.
|
||||
- Bad, because no existing team expertise — introduces a third framework.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Use Angular standalone components without NgModules
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Angular historically required all components, directives, and pipes to be declared inside NgModules. Angular 14 introduced standalone components as an opt-in. Angular 17+ made them the recommended default and they are required for the esbuild-based application builder. How should the application be structured?
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Standalone components (no NgModules)
|
||||
- NgModule-based architecture
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Standalone components (no NgModules)", because they are the Angular-recommended default since v17, provide explicit per-component dependency declarations, and are required for the esbuild application builder.
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- Each component explicitly declares its own `imports` — dependencies are visible and not hidden in a shared module.
|
||||
- Better tree-shaking: unused imports in one component don't affect others.
|
||||
- Aligns with Angular's long-term direction.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Slightly more verbose component decorators — each component lists its own imports rather than inheriting from a module.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Standalone components (no NgModules)
|
||||
|
||||
- Good, because explicit dependency declarations per component.
|
||||
- Good, because required for the esbuild application builder.
|
||||
- Good, because Angular-recommended default since v17.
|
||||
- Bad, because more verbose decorators.
|
||||
|
||||
### NgModule-based architecture
|
||||
|
||||
- Good, because familiar pattern for developers coming from Angular < 14.
|
||||
- Bad, because deprecated direction; incompatible with the modern application builder.
|
||||
- Bad, because shared module patterns hide dependencies.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Coexist Angular Material and Bootstrap during UI library consolidation
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The application currently uses two UI libraries: Angular Material (interactive components) and Bootstrap 5 (layout, spacing utilities). Bootstrap was the original styling layer; Angular Material was introduced incrementally. Maintaining both increases bundle size, creates styling inconsistencies, and adds cognitive overhead. Which strategy should be adopted?
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Keep both libraries and consolidate later
|
||||
- Consolidate to Angular Material only
|
||||
- Consolidate to Bootstrap only
|
||||
- Migrate to a different library (PrimeNG, Spartan/shadcn-angular, etc.)
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Keep both libraries and consolidate later", because a big-bang UI rewrite is not justified at this stage and the final target library has not yet been chosen.
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- No immediate rewrite required — development velocity is preserved.
|
||||
- Each library covers the other's gaps during the transition period.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Two styling systems in tension, leading to inconsistent UX.
|
||||
- Bootstrap's `@import`-based Sass is incompatible with the `@use` module system, requiring a deprecation workaround (see ADR 0012).
|
||||
- Larger CSS bundle than a single-library solution.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Keep both libraries and consolidate later
|
||||
|
||||
- Good, because no rewrite cost now.
|
||||
- Bad, because styling inconsistency and larger bundle persist until consolidation.
|
||||
|
||||
### Consolidate to Angular Material only
|
||||
|
||||
- Good, because single coherent design system.
|
||||
- Bad, because Bootstrap layout utilities would need to be replaced.
|
||||
|
||||
### Consolidate to Bootstrap only
|
||||
|
||||
- Good, because removes Angular Material dependency.
|
||||
- Bad, because Angular Material components (dialogs, date pickers, etc.) would need rebuilding.
|
||||
|
||||
### Migrate to a different library
|
||||
|
||||
- Good, because could address limitations of both current libraries.
|
||||
- Bad, because highest rewrite cost; unknown tradeoffs until evaluated.
|
||||
|
||||
## Links
|
||||
|
||||
- Related to [ADR 0012](0012-scss-bootstrap-import-deprecation.md)
|
||||
@@ -0,0 +1,46 @@
|
||||
# Handle API base URL, auth token, and errors via HTTP interceptors
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Every API call requires three cross-cutting concerns: prepending the API base URL, attaching the JWT bearer token, and unwrapping error responses into a consistent shape. How should these be applied across all HTTP calls without duplicating logic in each service?
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Three chained HTTP interceptors
|
||||
- Per-service base URL and token handling
|
||||
- Single utility wrapper function
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Three chained HTTP interceptors", because they centralise cross-cutting concerns transparently and require no changes to individual services.
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- Services use relative paths (e.g. `/skydive/jumps`) — environment-agnostic.
|
||||
- Auth header injection is transparent to all services.
|
||||
- Uniform error payload shape across the entire application.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Services must always use relative paths starting with `/`. Passing an absolute URL would cause the base URL to be prepended twice.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Three chained HTTP interceptors
|
||||
|
||||
- Good, because zero duplication — logic defined once, applied everywhere.
|
||||
- Good, because services remain unaware of environment or auth details.
|
||||
- Bad, because interceptors apply globally — opting out requires explicit handling.
|
||||
|
||||
### Per-service base URL and token handling
|
||||
|
||||
- Good, because each service controls its own behaviour explicitly.
|
||||
- Bad, because duplicates the same logic across every service.
|
||||
|
||||
### Single utility wrapper function
|
||||
|
||||
- Good, because more explicit than interceptors.
|
||||
- Bad, because all service calls must go through the wrapper — easy to bypass accidentally.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Store JWT authentication token in localStorage
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The application requires persistent authentication across browser sessions. Where should the JWT token be stored client-side?
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Application is internal — used by a known, controlled user base; not publicly accessible.
|
||||
- Implementation simplicity.
|
||||
- Compatibility with Angular functional guards and the APP_INITIALIZER auth bootstrap.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Store JWT in localStorage
|
||||
- Store JWT in an httpOnly cookie
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Store JWT in localStorage", because the application is internal with a controlled user base, making the XSS risk acceptable, and localStorage avoids the additional CSRF complexity of cookie-based auth.
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- Simple implementation — no server-side session management.
|
||||
- Token persists across browser restarts without requiring re-login.
|
||||
- Works seamlessly with Angular functional guards and `APP_INITIALIZER`.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Accessible to JavaScript — an XSS attack could exfiltrate the token.
|
||||
- If the application ever becomes publicly accessible or handles sensitive data, this decision must be revisited.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Store JWT in localStorage
|
||||
|
||||
- Good, because simple to implement and operate.
|
||||
- Good, because survives browser restarts.
|
||||
- Bad, because XSS-vulnerable.
|
||||
|
||||
### Store JWT in an httpOnly cookie
|
||||
|
||||
- Good, because inaccessible to JavaScript — XSS-safe.
|
||||
- Bad, because requires CSRF protection (SameSite cookie policy or CSRF tokens).
|
||||
- Bad, because more complex server-side coordination.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Pre-fetch route data using Angular route resolvers
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Route components need data to render. Should that data be fetched before the route activates (resolver) or after the component renders (in-component)?
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Angular route resolvers
|
||||
- In-component fetching (ngOnInit)
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Angular route resolvers", because components receive data immediately via `ActivatedRoute.data`, eliminating the need for per-component loading states on initial render.
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- No loading state needed in components for the initial data fetch.
|
||||
- Loading indication can be centralised at the router level via router events.
|
||||
- Data availability is guaranteed before any component lifecycle hook runs.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Navigation appears paused while the resolver fetches — there is no partial render before data arrives.
|
||||
- Resolver errors must be handled explicitly to avoid navigation hangs on API failure.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Angular route resolvers
|
||||
|
||||
- Good, because data is available immediately in the component.
|
||||
- Good, because loading logic is centralised outside the component.
|
||||
- Bad, because navigation is blocked until the resolver completes.
|
||||
|
||||
### In-component fetching (ngOnInit)
|
||||
|
||||
- Good, because navigation is instant — component renders immediately.
|
||||
- Bad, because each component must manage its own loading and error states.
|
||||
- Bad, because inconsistent UX across routes if not implemented uniformly.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Use the esbuild-based Angular application builder
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Angular 17 introduced a new application builder (`@angular/build:application`) based on esbuild and Vite, replacing the legacy webpack-based `@angular-devkit/build-angular:browser` builder. Which builder should be used for development and production builds?
|
||||
|
||||
## Considered Options
|
||||
|
||||
- `@angular/build:application` — esbuild-based (Angular 17+)
|
||||
- `@angular-devkit/build-angular:browser` — webpack-based (legacy)
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "`@angular/build:application`", because it is the Angular-recommended builder since v17, delivers significantly faster builds, and is required for certain schema-level features such as `stylePreprocessorOptions.sass.silenceDeprecations`.
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- Build times are 3–5× faster than the webpack builder for development and production.
|
||||
- Native ESM output with better code splitting.
|
||||
- Aligns with Angular's recommended toolchain for v17+.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Webpack-specific configuration options (custom plugins, loaders) no longer apply. Not an issue for this project.
|
||||
- The `sass` sub-option under `stylePreprocessorOptions` is specific to this builder's schema; the Karma test builder uses a stricter schema and does not support it.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### `@angular/build:application` (esbuild)
|
||||
|
||||
- Good, because significantly faster cold builds and incremental rebuilds.
|
||||
- Good, because Angular-recommended default since v17.
|
||||
- Good, because required for `sass.silenceDeprecations` support.
|
||||
- Bad, because webpack plugin ecosystem is not compatible.
|
||||
|
||||
### `@angular-devkit/build-angular:browser` (webpack)
|
||||
|
||||
- Good, because supports custom webpack configuration.
|
||||
- Bad, because deprecated direction; slower builds.
|
||||
- Bad, because does not support the `sass.silenceDeprecations` option.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Keep Karma + Jasmine as the test stack
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Angular's default test setup uses Karma as the test runner (executing tests in a real Chrome browser) and Jasmine as the spec framework. Faster alternatives exist. Should the test stack be replaced?
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Karma + Jasmine (Angular default)
|
||||
- Jest (Node/jsdom, no real browser)
|
||||
- Vitest (ESM-native, no real browser)
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Karma + Jasmine", because the test suite (54 specs) is small enough that Karma's startup overhead is not a bottleneck, and migration would require replacing test utilities and adjusting Angular `TestBed` setup with no functional gain at the current scale.
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- Zero migration effort — full compatibility with Angular's `TestBed` and `ComponentFixture` APIs.
|
||||
- Tests run in a real Chrome browser, catching browser-specific issues that jsdom misses.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Slower startup than Jest or Vitest due to Chrome launch. Acceptable at the current test suite size.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Karma + Jasmine
|
||||
|
||||
- Good, because zero migration cost from the Angular default.
|
||||
- Good, because tests run in a real browser environment.
|
||||
- Bad, because slower startup than headless alternatives.
|
||||
|
||||
### Jest
|
||||
|
||||
- Good, because fast — runs in Node via jsdom with no browser launch.
|
||||
- Bad, because requires `jest-preset-angular`, removal of `zone.js/testing`, and test utility changes.
|
||||
- Bad, because jsdom is not a real browser — some browser-specific behaviours are not caught.
|
||||
|
||||
### Vitest
|
||||
|
||||
- Good, because ESM-native, fastest option.
|
||||
- Bad, because Angular `TestBed` integration requires additional configuration.
|
||||
- Bad, because jsdom limitation applies here too.
|
||||
|
||||
## Links
|
||||
|
||||
- Future consideration: if the test suite grows significantly or CI performance becomes a concern, evaluate migration to Jest with `jest-preset-angular`.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Enforce quality gates at commit time with Husky and lint-staged
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Without automated enforcement, code quality rules (linting, formatting, tests) are discovered late — in CI, or not at all. Should quality checks be enforced at commit time?
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Issues should be caught before they enter the repository, not after.
|
||||
- Formatting should be applied automatically so developers don't need to run it manually.
|
||||
- The correct Node version must be active during hook execution regardless of the shell environment.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Husky + lint-staged pre-commit hook
|
||||
- CI-only enforcement (no local hooks)
|
||||
- No automated enforcement
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Husky + lint-staged pre-commit hook", because it prevents lint errors and failing tests from entering the repository, and auto-fixes formatting on commit without developer intervention.
|
||||
|
||||
The hook runs in order:
|
||||
|
||||
1. Full test suite (`npm test -- --watch=false`) — blocks commit on any failing spec.
|
||||
2. `lint-staged` on staged files: `prettier --write` on `*.{ts,html,scss,json,md}`, then `eslint --max-warnings=0` on `*.ts` and `*.html`.
|
||||
|
||||
nvm is initialised inside the hook script to ensure the correct Node version (pinned in `.nvmrc`) is active regardless of the shell environment that triggered the commit.
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- No lint errors or failing tests can enter the repository via a normal commit.
|
||||
- Prettier auto-fixes formatting on commit — developers don't need to run it manually.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Commits are slower (~10–20 s for the full test suite). Acceptable trade-off for a solo project.
|
||||
- Requires nvm to be installed on the developer's machine. The hook sources `$HOME/.nvm/nvm.sh` directly.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Husky + lint-staged
|
||||
|
||||
- Good, because issues are caught before they enter the repository.
|
||||
- Good, because formatting is applied automatically.
|
||||
- Bad, because adds latency to every commit.
|
||||
- Bad, because requires nvm on the developer's machine.
|
||||
|
||||
### CI-only enforcement
|
||||
|
||||
- Good, because no local overhead.
|
||||
- Bad, because broken commits are discovered only after push.
|
||||
|
||||
### No automated enforcement
|
||||
|
||||
- Good, because zero overhead.
|
||||
- Bad, because quality degrades without consistent manual discipline.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Pin Node version with nvm and .nvmrc
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Angular 18 requires Node.js `^18.19.1 || ^20.11.1`. The system Node version on a development machine may not meet this requirement, causing silent build failures or CLI errors. How should the required Node version be enforced?
|
||||
|
||||
## Considered Options
|
||||
|
||||
- nvm with `.nvmrc`
|
||||
- System Node (no version management)
|
||||
- Other version managers (fnm, volta)
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "nvm with `.nvmrc`", because it pins the version explicitly in the repository, is already in use on the development machine, and integrates directly with the Husky pre-commit hook.
|
||||
|
||||
A `.nvmrc` file at the repository root pins Node to `20.19.6`. The Husky pre-commit hook explicitly calls `nvm use 20.19.6` before running tests to ensure the correct version is active in the hook's shell environment.
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- Consistent Node version across all commands: dev server, build, tests, pre-commit hook.
|
||||
- `.nvmrc` documents the required Node version explicitly in the repository.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Requires nvm. Developers using other version managers (fnm, volta) must align manually.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### nvm with `.nvmrc`
|
||||
|
||||
- Good, because explicit version pinning visible in the repository.
|
||||
- Good, because integrates with Husky hook via `nvm use`.
|
||||
- Bad, because tied to nvm — other version managers need manual alignment.
|
||||
|
||||
### System Node
|
||||
|
||||
- Good, because no tooling required.
|
||||
- Bad, because version is not pinned — any system upgrade can silently break the build.
|
||||
|
||||
### Other version managers (fnm, volta)
|
||||
|
||||
- Good, because faster and more ergonomic than nvm for some workflows.
|
||||
- Bad, because not the currently installed tool — switching would require migration.
|
||||
|
||||
## Links
|
||||
|
||||
- The `package.json` `engines` field (`^18.19.1 || ^20.11.1`) provides a secondary signal for tooling that reads it.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Organise code by functional domain
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The application covers four independent functional areas with distinct data models, API backends, and user audiences: Skydive, CMS, E-commerce, and Hero Wars. Without explicit organisational boundaries, code from different domains would intermingle, making each area harder to reason about independently. How should the codebase be structured?
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Domain-based organisation (one folder per functional area)
|
||||
- Flat feature-based organisation (one folder per component type)
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Domain-based organisation", because it mirrors the backend's route grouping, making the full-stack data flow traceable, and allows each domain to be understood and modified independently.
|
||||
|
||||
Services are placed under `src/app/core/services/<domain>/`, API prefixes are `/skydive`, `/cms`, `/ecommerce`, `/herowars`, and routes are composed from `auth.routes.ts` and `noauth.routes.ts` with domain-specific guards.
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- Each domain can be understood and modified independently.
|
||||
- Consistent mapping between frontend service paths and backend API routes simplifies debugging.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Cross-domain features (shared auth, user profile) must be placed in `core/` to avoid circular dependencies.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Domain-based organisation
|
||||
|
||||
- Good, because domain boundaries are explicit and enforced by directory structure.
|
||||
- Good, because mirrors the backend route grouping — frontend `core/services/skydive/` maps to backend `src/routes/api/skydive/`.
|
||||
- Bad, because shared cross-domain concerns need a neutral `core/` layer.
|
||||
|
||||
### Flat feature-based organisation
|
||||
|
||||
- Good, because all components of the same type are co-located.
|
||||
- Bad, because domain boundaries are invisible — mixing domains is too easy.
|
||||
|
||||
## Links
|
||||
|
||||
- Hero Wars uses static JSON files (`src/files-data/`) rather than live API calls — weekly snapshots updated manually. This is intentional and not a gap in the domain structure.
|
||||
- Related to backend [ADR 0004](../../adastra_api/docs/decisions/0004-route-organisation-by-domain.md)
|
||||
@@ -0,0 +1,57 @@
|
||||
# Silence the Sass @import deprecation for the Bootstrap import chain
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Bootstrap 5 relies on Sass's `@import` rule and `!default` variable overrides: our custom `_variable.scss` must be loaded before Bootstrap's `variables.scss` so that Bootstrap picks up our values via the `!default` mechanism. Dart Sass 1.80+ deprecates `@import` in favour of the `@use` module system. How should the deprecation be addressed?
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Bootstrap 5's `!default` override mechanism is incompatible with `@use` module scopes — variables loaded via `@use` do not flow into Bootstrap's module scope.
|
||||
- Other Dart Sass deprecations (`color.red/green/blue()`, global `if()`) were fixed properly.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Silence the `@import` deprecation via `angular.json`
|
||||
- Migrate to `@use` with `@use "bootstrap" with (...)`
|
||||
- Wait for Bootstrap 6
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Silence the `@import` deprecation via `angular.json`", because the `@use` migration is blocked by Bootstrap 5's architecture and Bootstrap 6 (which adopts `@use` natively) is not yet released.
|
||||
|
||||
The deprecation is suppressed in `angular.json` via `stylePreprocessorOptions.sass.silenceDeprecations: ["import"]`. The existing `@import`-based Bootstrap loading chain is kept intact. All other Dart Sass deprecations are fixed properly.
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- Bootstrap customisation works correctly with no behaviour change.
|
||||
- No risk of introducing regressions by attempting an incompatible migration now.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Deprecation is silenced rather than fixed. `@import` will be removed in Dart Sass 3.0.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Silence the `@import` deprecation
|
||||
|
||||
- Good, because no behaviour change — existing variable overrides continue to work.
|
||||
- Bad, because the underlying issue is deferred, not resolved.
|
||||
|
||||
### Migrate to `@use` with `@use "bootstrap" with (...)`
|
||||
|
||||
- Good, because fully resolves the deprecation.
|
||||
- Bad, because requires rewriting all Bootstrap variable overrides using `with ()` syntax — significant effort.
|
||||
- Bad, because the `with ()` syntax has limitations compared to `!default` chaining.
|
||||
|
||||
### Wait for Bootstrap 6
|
||||
|
||||
- Good, because Bootstrap 6 adopts `@use` natively — migration path will be documented by the Bootstrap team.
|
||||
- Bad, because Bootstrap 6 release date is unknown; deferring means living with the silenced warning.
|
||||
|
||||
## Links
|
||||
|
||||
- Related to [ADR 0003](0003-dual-ui-library-progressive-migration.md)
|
||||
- Future action: when Bootstrap 6 is released with `@use` support, migrate the import chain, remove the `silenceDeprecations` entry, and supersede this ADR.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Migrate HeroWars static JSON imports to HttpClient assets
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-04-26
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Six large JSON files (36 KB–3 MB) were statically imported directly into Angular components and services via TypeScript `import` statements. This bundled all the data into the main JavaScript bundle, inflating the initial download regardless of whether the user navigated to the HeroWars section. The largest file (`hw-guild-raids.json`, 3 MB) alone would significantly delay first contentful paint for all users.
|
||||
|
||||
Additionally, a seventh file (`qcm-bpa.json`, 210 KB) was imported but never consumed — the component had switched to a route resolver but the dead import remained.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Static JSON imports are compiled into the bundle — they cannot be lazy-loaded or cached by the browser independently.
|
||||
- Updating the data files required a recompile and redeployment; serving them as assets allows hot-swapping without a rebuild.
|
||||
- The HeroWars section is used by a small subset of users; its data should not penalise all other users' load time.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- Keep static imports
|
||||
- Move JSON to assets and load via `HttpClient`
|
||||
- Fetch data from the API backend
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Move JSON to assets and load via HttpClient", because it removes the data from the bundle, enables browser-level HTTP caching, and requires no backend changes. The files are not secret (guild-internal analytics data) so serving them as static assets is appropriate.
|
||||
|
||||
Structure:
|
||||
|
||||
- JSON files moved from `src/files-data/` to `src/assets/files-data/`
|
||||
- `HWDataService` created at `src/app/core/services/herowars/hw-data.service.ts` — exposes one `readonly` observable per file, each piped through `shareReplay(1)` so the HTTP request fires at most once per app lifecycle
|
||||
- `HWClanService.loadClan()` and `HWMemberService.loadMembers()` converted from synchronous return values to `Observable<T>`
|
||||
- All consumer components inject `HWDataService` and subscribe in `ngOnInit()`; template-called methods that previously accessed `guildWarSlots.slots` inline now read from a `_slots` class property populated in the subscribe callback
|
||||
- Unused `import data from 'src/qcm-bpa.json'` and two dead import-helper methods removed from `qcm.component.ts`
|
||||
|
||||
### Positive Consequences
|
||||
|
||||
- HeroWars JSON (~4 MB total) no longer shipped in the initial bundle.
|
||||
- Each JSON file is independently cacheable by the browser with standard HTTP cache headers.
|
||||
- Data files can be updated without recompiling the Angular application.
|
||||
- `shareReplay(1)` ensures a single HTTP request per session even when multiple components subscribe to the same observable.
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
- Components now initialize asynchronously — there is a brief render before data arrives (consistent with the rest of the app's HTTP-driven components).
|
||||
- `Object.entries()` on `any`-typed HTTP responses requires explicit `Record<string, T>` casts to satisfy strict TypeScript, adding minor verbosity.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Keep static imports
|
||||
|
||||
- Good, because synchronous — no async lifecycle complexity.
|
||||
- Bad, because adds up to 4 MB to the initial bundle.
|
||||
- Bad, because data updates require a full rebuild and redeployment.
|
||||
|
||||
### Move JSON to assets and load via HttpClient
|
||||
|
||||
- Good, because removes data from the bundle entirely.
|
||||
- Good, because browser-cacheable independently of the app JS.
|
||||
- Good, because hot-swappable without a recompile.
|
||||
- Bad, because converts synchronous service methods to observables, requiring component updates.
|
||||
|
||||
### Fetch from API backend
|
||||
|
||||
- Good, because data could be managed dynamically via the admin interface.
|
||||
- Bad, because requires significant backend work (new endpoints, data model) for data that is currently managed as flat files.
|
||||
- Bad, because adds latency and a failure mode not present with local assets.
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square70x70logo src="/ms-icon-70x70.png"/>
|
||||
<square150x150logo src="/ms-icon-150x150.png"/>
|
||||
<square310x310logo src="/ms-icon-310x310.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 3.5 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"}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||