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
+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';