feat(portal-admin): skeleton app per ADR-0020 (#100)
## 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:
@@ -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)],
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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: '',
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
@@ -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 d’administration pour le portail APF. Les premiers modules fonctionnels — gestion de contenu, gestion du menu, liste des utilisateurs, visualiseur d’audit log — arrivent dans les PRs à venir conformément à l’ADR-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>
|
||||
@@ -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));
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user