feat(portal-admin): skeleton app per ADR-0020 (#100)
CI / check (push) Successful in 2m49s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m32s
CI / a11y (push) Successful in 1m26s
CI / perf (push) Successful in 4m17s

## Summary

First step of the admin track per ADR-0020. Scaffold the second Angular SPA in the workspace alongside `portal-shell`, with the architectural decisions from the v1 ADRs already wired so subsequent feature PRs can drop straight into modules.

## What lands

- **App generated** via `nx g @nx/angular:application` (with the matching e2e project skeleton). Standalone, zoneless-ready, prefix `app`, scss component styles, css root stylesheet, no SSR.
- **Tags** `scope:portal-admin, type:app` — module-boundary lint now treats `portal-admin` distinctly from `portal-shell` and lets it depend only on `scope:portal-admin` + `scope:shared` libs.
- **Tailwind v4 + brand tokens**: `.postcssrc.json`, `@import 'tailwindcss'`, class-based dark variant, and `@import '../../../libs/shared/tokens/src/brand-tokens.css'` — identical visual baseline to portal-shell.
- **i18n config mirrors portal-shell** (per ADR-0019): sourceLocale `en`, target `fr`, baseHref `/{locale}/` per locale, `@angular/localize/init` polyfill, `localize: ["en", "fr"]` on production, `i18nMissingTranslation: "error"` so CI blocks any PR that adds a marker without translating it. Seed [`messages.fr.xlf`](apps/portal-admin/src/locale/messages.fr.xlf) carries the four marked strings used today.
- **Budgets** relaxed to **500 KB gzip initial** per ADR-0020 §"Performance budgets" (vs 300 KB for portal-shell).
- **Skeleton home page** at `/` — `<h1>` + intro paragraph + "Skeleton — coming soon" chip in the brand-accent palette. All marked for i18n.
- **Skip-link landmark** (WCAG 2.4.1) with the same component-scoped scss as portal-shell.
- **Wildcard catch-all route** → bounces unknown paths to home (same defensive pattern as PR #96 on portal-shell).
- **Dev server on port 4300** (vs 4200 for portal-shell) — both can run in parallel.
- **`serve-static` target without `spa: true`** — locale-prefixed routing in production is handled by the reverse proxy (per ADR-0019), same as portal-shell since PR #92.

## CLAUDE.md

Picked up the second app in the **Naming** entry and the **Commands** snippet (`<app>` is now one of `portal-shell`, `portal-admin`, `portal-bff`).

## Verification

- `pnpm exec nx run-many -t lint test build --projects=portal-shell,portal-admin,shared-ui,shared-state,shared-tokens` — green.
- `portal-admin` test: 1 spec (skip-link + main landmark present).
- Production build of `portal-admin`: **62 KB gzip initial per locale** (vs 500 KB budget — plenty of headroom for the upcoming modules).
- Both `dist/apps/portal-admin/browser/{en,fr}/` emit with the right `<html lang>` and `<base href>`. FR bundle contains `"Administration"` / `"Squelette"` / `"bientôt disponible"` — translations applied.
- portal-shell production build unchanged.

## Out of scope (each its own follow-up PR)

- Admin app shell (header / sidebar / footer with "Admin" badge — likely sharing graduated primitives plus admin-specific bits).
- OpenTelemetry tracing setup (`service.name=portal-admin` per ADR-0012).
- `environment.ts` wiring (per ADR-0018) for admin-specific endpoints.
- BFF `AdminModule` + `AdminRoleGuard` + smoke `/api/admin/me` (per ADR-0020 §"Auth").
- First functional module — CMS / menu / users / audit viewer.

## Test plan

- [x] Lint + test + build green across the workspace.
- [x] Per-locale production build emits both folders with correct metadata.
- [ ] Manual: `pnpm exec nx serve portal-admin` boots on `:4300`, smoke page renders.
- [ ] Manual: prod build + `pnpm exec nx run portal-admin:serve-static` → `http://localhost:4300/en/` and `/fr/` show the placeholder with the matching language.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #100
This commit was merged in pull request #100.
This commit is contained in:
2026-05-12 01:49:19 +02:00
parent 8329fa133d
commit d962be838a
25 changed files with 520 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
+34
View File
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
+123
View File
@@ -0,0 +1,123 @@
{
"name": "portal-admin",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/portal-admin/src",
"tags": ["scope:portal-admin", "type:app"],
"i18n": {
"sourceLocale": {
"code": "en",
"baseHref": "/en/"
},
"locales": {
"fr": {
"translation": "apps/portal-admin/src/locale/messages.fr.xlf",
"baseHref": "/fr/"
}
}
},
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/portal-admin",
"browser": "apps/portal-admin/src/main.ts",
"polyfills": ["@angular/localize/init"],
"tsConfig": "apps/portal-admin/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "apps/portal-admin/public"
}
],
"styles": ["apps/portal-admin/src/styles.css"]
},
"configurations": {
"production": {
"localize": ["en", "fr"],
"i18nMissingTranslation": "error",
"budgets": [
{
"type": "initial",
"maximumWarning": "1.5mb",
"maximumError": "1.5mb"
},
{
"type": "anyScript",
"maximumWarning": "500kb",
"maximumError": "500kb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "5kb",
"maximumError": "6kb"
},
{
"type": "bundle",
"name": "styles",
"maximumWarning": "150kb",
"maximumError": "150kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"extract-i18n": {
"executor": "@angular/build:extract-i18n",
"options": {
"buildTarget": "portal-admin:build",
"outputPath": "apps/portal-admin/src/locale",
"outFile": "messages.xlf"
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"options": {
"port": 4300
},
"configurations": {
"production": {
"buildTarget": "portal-admin:build:production"
},
"development": {
"buildTarget": "portal-admin:build:development"
}
},
"defaultConfiguration": "development"
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
},
"configurations": {
"watch": {
"watch": true
}
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "portal-admin:build",
"port": 4300,
"staticFilePath": "dist/apps/portal-admin/browser"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+7
View File
@@ -0,0 +1,7 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(appRoutes)],
};
+4
View File
@@ -0,0 +1,4 @@
<a class="skip-link" href="#main-content" i18n="@@app.skipLink">Skip to main content</a>
<main id="main-content" tabindex="-1" class="min-h-screen bg-gray-50 dark:bg-gray-950">
<router-outlet />
</main>
+22
View File
@@ -0,0 +1,22 @@
import { Route } from '@angular/router';
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
export const appRoutes: Route[] = [
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./pages/home/home').then((m) => m.Home),
title: homeTitle,
},
// Catch-all — unknown paths bounce to home. Same role as the
// wildcard in portal-shell (per PR #96): in production each locale
// bundle ships with its own `<base href="/{locale}/">` and the
// router never sees the locale segment, so this only fires for
// genuine 404 paths; in dev (`nx serve`) it stops the router from
// throwing NG04002 when a stray `/fr/`-style URL is typed.
{
path: '**',
redirectTo: '',
},
];
+23
View File
@@ -0,0 +1,23 @@
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
// visible and focusable when reached via Tab from the address bar.
// Plain CSS rather than the `sr-only` Tailwind utilities so the
// visual state on focus is as predictable as possible across themes.
.skip-link {
position: absolute;
top: -40px;
left: 0.75rem;
z-index: 50;
padding: 0.5rem 0.75rem;
background: var(--color-brand-primary-500);
color: #fff;
border-radius: 0.25rem;
text-decoration: none;
transition: top 0.15s ease-out;
&:focus,
&:focus-visible {
top: 0.5rem;
outline: 2px solid var(--color-brand-accent-300);
outline-offset: 2px;
}
}
+20
View File
@@ -0,0 +1,20 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])],
}).compileComponents();
});
it('renders the layout shell — skip link, main landmark', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('a.skip-link')).not.toBeNull();
expect(root.querySelector('main#main-content')).not.toBeNull();
});
});
+11
View File
@@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {}
@@ -0,0 +1,24 @@
<section class="mx-auto max-w-3xl px-6 py-12">
<h1
class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
i18n="@@page.home.title"
>
APF Portal Admin
</h1>
<p
class="mt-4 text-base leading-relaxed text-gray-700 dark:text-gray-300"
i18n="@@page.home.intro"
>
Administrative back-office for the APF Portal. The first functional modules — content
management, menu administration, user list, audit log viewer — land in upcoming PRs per
ADR-0020.
</p>
<p
class="mt-6 inline-flex items-center rounded-full border border-brand-accent-300 bg-brand-accent-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-brand-accent-700 dark:border-brand-accent-700 dark:bg-brand-accent-950/40 dark:text-brand-accent-200"
role="status"
i18n="@@page.home.status"
>
Skeleton — coming soon
</p>
</section>
@@ -0,0 +1,16 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
/**
* Smoke-test landing page for portal-admin.
*
* v1 ships a placeholder. The real admin shell (header + sidebar +
* footer with the "Admin" badge, per ADR-0020) lands in a follow-up
* PR together with the first functional module (CMS pages, menu
* management, user list, or audit log viewer).
*/
@Component({
selector: 'app-home',
templateUrl: './home.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Home {}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>APF Portal Admin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<!-- app -->
<trans-unit id="app.skipLink" datatype="html">
<source>Skip to main content</source>
<target>Aller au contenu principal</target>
</trans-unit>
<!-- route -->
<trans-unit id="route.home.title" datatype="html">
<source>APF Portal Admin</source>
<target>Administration APF Portal</target>
</trans-unit>
<!-- page.home -->
<trans-unit id="page.home.title" datatype="html">
<source> APF Portal Admin </source>
<target> Administration APF Portal </target>
</trans-unit>
<trans-unit id="page.home.intro" datatype="html">
<source> Administrative back-office for the APF Portal. The first functional modules — content management, menu administration, user list, audit log viewer — land in upcoming PRs per ADR-0020. </source>
<target> Back-office dadministration pour le portail APF. Les premiers modules fonctionnels — gestion de contenu, gestion du menu, liste des utilisateurs, visualiseur daudit log — arrivent dans les PRs à venir conformément à lADR-0020. </target>
</trans-unit>
<trans-unit id="page.home.status" datatype="html">
<source> Skeleton — coming soon </source>
<target> Squelette — bientôt disponible </target>
</trans-unit>
</body>
</file>
</xliff>
+5
View File
@@ -0,0 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
+18
View File
@@ -0,0 +1,18 @@
/*
* Global styles for portal-admin.
*
* Same baseline as portal-shell — Tailwind CSS 4 (CSS-first config),
* class-based dark mode, brand palette tokens shared from
* `libs/shared/tokens`. The two apps wear the same visual chrome so
* an APF user moving between portal and admin doesn't feel a context
* switch in the design system itself; what differentiates the admin
* surface is the data density and the "Admin" badge in the shell
* (added once the admin shell PR ships).
*/
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* Brand palette tokens — shared with portal-shell. */
@import '../../../libs/shared/tokens/src/brand-tokens.css';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["@angular/localize"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals", "@angular/localize"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}