diff --git a/README.md b/README.md index 04954db..0afec92 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Foundry Stack +# Next Template Angular Parity -Static-first Angular showcase and template website with an optional server/backend path. +Localized Angular application foundation modeled after the deployed `next-template` reference. It includes locale routing, navigation menus, hotkeys, auth screens, form and table demos, communication notes, uploads, repo-managed content, problem reporting, static data providers, and optional server/API providers. ## Scripts @@ -10,9 +10,43 @@ npm run build npm run build:github-pages npm run build:connected npm run build:server -npm test -- --watch=false +npm run test:unit +npm run test:integration +npm run test:e2e +npm run test:ci ``` +## Public routes + +- `/` redirects to `/en` +- `/:locale` +- `/:locale/about` +- `/:locale/login` +- `/:locale/register` +- `/:locale/examples/forms` +- `/:locale/examples/story` +- `/:locale/examples/communication` +- `/:locale/examples/uploads` +- `/:locale/table` +- `/:locale/remocn` +- `/:locale/blog` +- `/:locale/changelog` +- `/:locale/report-problem` +- `/:locale/**` + +Supported locales are `en` and `de`. Locale links preserve the current path when switching languages. + +## Feature surface + +- Reference-style app shell with Discover and Workspace menus. +- Alt-key navigation shortcuts and a hotkey dialog. +- Light/dark theme persistence with an inline startup script to reduce theme flash. +- Privacy consent persistence for necessary-only or analytics opt-in. +- Reactive auth, registration, reset, employee profile, newsletter, and report-problem forms. +- Upload queue with file classification and strategy suggestions. +- Employee table with repository-backed data and loading/error/empty states. +- Static blog and changelog content stored as typed records. + ## Deployment modes - `build`: browser-only production build for static hosting. @@ -20,25 +54,21 @@ npm test -- --watch=false - `build:connected`: same frontend with API-backed repository providers. - `build:server`: browser + server bundles with Express endpoints under `/api/*`. -## GitHub Actions +## Server endpoints -- `.github/workflows/github-pages.yml`: tests the app, builds the static site, and deploys `dist/angular2/browser` to GitHub Pages. -- `.github/workflows/server-build.yml`: tests the app, builds the optional server variant, and uploads `dist/angular2` as an artifact. - -## GitHub Pages setup - -- Enable GitHub Pages in the repository settings and set the source to `GitHub Actions`. -- The Pages workflow derives `baseHref` from the repository name automatically. -- For local verification you can override the path: - -```bash -GITHUB_PAGES_BASE_HREF=/angular/ npm run build:github-pages -``` +- `POST /api/auth/login` +- `POST /api/auth/register` +- `POST /api/auth/password-reset` +- `POST /api/newsletter` +- `POST /api/problem-reports` +- `GET /api/employees` +- Existing template, showcase, and contact endpoints remain available. ## Architecture -- Standalone, lazy-loaded Angular routes. -- Signals for local page state. -- Typed in-repo content for templates and showcases. -- Repository abstraction to switch between static and API data. -- Optional Express service endpoints for templates, showcases, and contact submissions. +- Standalone lazy-loaded Angular routes. +- URL-based locale service for `en` and `de`. +- Signals for local UI state. +- Reactive Forms for user input. +- Repository abstraction for static, connected, and server-backed behavior. +- GitHub Pages fallback routing through a copied `404.html`. diff --git a/e2e/app.e2e.ts b/e2e/app.e2e.ts index 12ed0fc..a5ad8a9 100644 --- a/e2e/app.e2e.ts +++ b/e2e/app.e2e.ts @@ -1,52 +1,46 @@ import { expect, test } from '@playwright/test'; -test('navigates from the home page through templates to the contact flow', async ({ page }) => { +test('redirects to localized home and supports locale switching plus hotkeys', async ({ page }) => { await page.goto('/'); - await expect( - page.getByRole('heading', { - level: 1, - name: 'Build once for GitHub Pages, then grow into backend services without rewriting the UI.', - }), - ).toBeVisible(); + await expect(page).toHaveURL(/\/en$/); + await expect(page.getByRole('heading', { level: 1, name: 'One template, multiple production-ready starting points.' })).toBeVisible(); - await page.getByRole('link', { name: 'Browse templates' }).click(); - await expect(page).toHaveURL(/\/templates$/); + await page.getByRole('link', { name: 'DE' }).click(); + await expect(page).toHaveURL(/\/de$/); + await expect(page.getByRole('heading', { level: 1, name: 'Eine Vorlage mit mehreren produktionsnahen Ausgangspunkten.' })).toBeVisible(); - await page.getByRole('searchbox', { name: 'Search' }).fill('Atlas'); - await expect(page.getByText('1 templates matched.')).toBeVisible(); + await page.keyboard.press('Alt+F'); + await expect(page).toHaveURL(/\/de\/examples\/forms$/); + await expect(page.getByRole('heading', { level: 1, name: 'Mitarbeiterprofil-Formular' })).toBeVisible(); - await page.getByRole('link', { name: 'View template' }).click(); - await expect(page).toHaveURL(/\/templates\/atlas-launch-kit$/); - await expect(page.getByRole('heading', { level: 1, name: 'Atlas Launch Kit' })).toBeVisible(); - - await page.getByRole('link', { name: 'Request implementation' }).click(); - await expect(page).toHaveURL(/\/contact$/); - - await page.getByRole('textbox', { name: 'Name' }).fill('Alex'); - await page.getByRole('textbox', { name: 'Email' }).fill('alex@example.com'); - await page.getByRole('textbox', { name: 'Company' }).fill('Foundry'); - await page.getByRole('combobox', { name: 'Project type' }).selectOption('Migration'); - await page - .getByRole('textbox', { name: 'Message' }) - .fill('We need a migration path that keeps the public site static while backend services are phased in.'); - - await page.getByRole('button', { name: 'Send request' }).click(); - - await expect( - page.getByText( - 'Message captured in static mode. Swap to the connected or server build to send it to a backend.', - ), - ).toBeVisible(); + await page.getByRole('button', { name: /Show navigation hotkeys/ }).click(); + await expect(page.getByRole('dialog', { name: /Jump between routes/ })).toBeVisible(); }); -test('serves deep links and the not-found route from the built app', async ({ page }) => { - await page.goto('/showcase/northstar-ops'); - - await expect(page.getByRole('heading', { level: 1, name: 'Northstar Ops' })).toBeVisible(); - await expect(page.getByText('This project maps back to a reusable template system.')).toBeVisible(); - - await page.goto('/missing-route'); - - await expect(page.getByRole('heading', { level: 1, name: 'That page does not exist in this build.' })).toBeVisible(); +test('runs auth, upload, report, and not-found flows', async ({ page }) => { + await page.goto('/en/login'); + await page.getByRole('textbox', { name: 'Email' }).fill('alex@example.com'); + await page.getByLabel('Password').fill('password'); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByText('Signed in with the static auth adapter')).toBeVisible(); + + await page.goto('/en/examples/uploads'); + const chooserPromise = page.waitForEvent('filechooser'); + await page.getByText('Choose files').click(); + const chooser = await chooserPromise; + await chooser.setFiles({ + name: 'records.json', + mimeType: 'application/json', + buffer: Buffer.from('{}'), + }); + await expect(page.getByText('Validate schema')).toBeVisible(); + + await page.goto('/en/report-problem'); + await page.getByRole('textbox', { name: 'What happened?' }).fill('Saving changes closed the modal before the confirmation was visible.'); + await page.getByRole('button', { name: 'Send report' }).click(); + await expect(page.getByText(/Reference: STATIC-/)).toBeVisible(); + + await page.goto('/en/missing-route'); + await expect(page.getByRole('heading', { level: 1, name: 'The page does not exist.' })).toBeVisible(); }); diff --git a/src/app/app.css b/src/app/app.css index a7df26a..216c24e 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -13,12 +13,12 @@ position: absolute; left: 1rem; top: 1rem; + z-index: 50; transform: translateY(-180%); - z-index: 20; border-radius: 999px; padding: 0.75rem 1rem; background: var(--ink-950); - color: var(--sand-50); + color: var(--surface); text-decoration: none; } @@ -29,94 +29,252 @@ .site-header { position: sticky; top: 0; - z-index: 10; - backdrop-filter: blur(20px); - background: color-mix(in srgb, var(--sand-50) 88%, transparent); + z-index: 20; border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--surface) 86%, transparent); + backdrop-filter: blur(18px); } .site-header__inner, .site-footer__inner { - width: min(1120px, calc(100% - 2rem)); + width: min(1180px, calc(100% - 2rem)); margin: 0 auto; } .site-header__inner { + min-height: 4.5rem; display: flex; align-items: center; - justify-content: space-between; - gap: 1.25rem; - padding: 1rem 0; + gap: 1rem; + padding: 0.75rem 0; +} + +.brand, +.account-link, +.locale-link { + color: inherit; + text-decoration: none; } .brand { display: inline-flex; align-items: center; - gap: 0.85rem; - color: inherit; - text-decoration: none; + gap: 0.7rem; + font-weight: 800; } .brand__mark { - inline-size: 2.75rem; - block-size: 2.75rem; + inline-size: 2.4rem; + block-size: 2.4rem; display: grid; place-items: center; - border-radius: 0.85rem; - background: - radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.75), transparent 45%), - linear-gradient(135deg, var(--amber-500), var(--teal-500)); - color: var(--ink-950); - font-weight: 800; - letter-spacing: 0.08em; + border-radius: 0.7rem; + background: var(--ink-950); + color: var(--surface); + font-size: 0.8rem; } -.brand__copy { - display: grid; +.site-nav, +.site-actions { + display: flex; + align-items: center; + gap: 0.55rem; } -.brand__name { - font-size: 1rem; - font-weight: 700; +.site-nav { + margin-left: auto; +} + +.site-actions { + margin-left: 0.5rem; + flex-wrap: wrap; + justify-content: flex-end; } -.brand__tag { - color: var(--ink-500); - font-size: 0.82rem; +.nav-menu { + position: relative; } -.site-nav { - display: flex; +.nav-button, +.theme-button, +.icon-button, +.account-link, +.locale-link { + min-height: 2.45rem; + display: inline-flex; align-items: center; justify-content: center; - gap: 0.35rem; - flex-wrap: wrap; + gap: 0.45rem; + border-radius: 999px; + padding: 0 0.9rem; + font-weight: 700; + color: var(--ink-900); +} + +.nav-button { + border: 1px solid var(--ink-950); + background: var(--ink-950); + color: var(--surface); +} + +.nav-button--secondary, +.theme-button, +.icon-button, +.account-link, +.locale-link { + border: 1px solid var(--line-strong); + background: var(--surface-raised); + color: var(--ink-950); } -.site-nav__link, -.header-cta, -.site-footer__links a { +.locale-link { + min-width: 2.45rem; + padding-inline: 0.65rem; +} + +.locale-link--active { + border-color: var(--ink-950); +} + +.menu-panel { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + z-index: 30; + width: 18rem; + display: grid; + gap: 0.25rem; + border: 1px solid var(--line); + border-radius: 1rem; + padding: 0.5rem; + background: var(--surface-raised); + box-shadow: var(--shadow); +} + +.menu-panel a, +.hotkey-grid button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + border-radius: 0.75rem; + padding: 0.75rem; + color: inherit; text-decoration: none; + background: transparent; } -.site-nav__link { - padding: 0.6rem 0.9rem; - border-radius: 999px; - color: var(--ink-700); +.menu-panel a:hover, +.hotkey-grid button:hover { + background: var(--surface-muted); } -.site-nav__link--active, -.site-nav__link:hover { - background: var(--surface-strong); - color: var(--ink-950); +kbd { + min-width: 3.25rem; + border: 1px solid var(--line); + border-radius: 0.45rem; + padding: 0.15rem 0.35rem; + background: var(--surface-muted); + color: var(--ink-600); + font-size: 0.72rem; + text-align: center; +} + +.hotkey-dialog { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: start center; + padding: 6rem 1rem 1rem; + background: rgba(10, 12, 16, 0.5); +} + +.hotkey-dialog__panel { + width: min(720px, 100%); + max-height: calc(100dvh - 8rem); + overflow: auto; + border: 1px solid var(--line); + border-radius: 1rem; + background: var(--surface-raised); + box-shadow: var(--shadow); + padding: 1.25rem; +} + +.dialog-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 1rem; } -.header-cta { - padding: 0.85rem 1.1rem; +.dialog-heading h2 { + margin: 0.2rem 0 0; + font-size: 1.25rem; +} + +.icon-only { + inline-size: 2.25rem; + block-size: 2.25rem; border-radius: 999px; - background: var(--ink-950); - color: var(--sand-50); - font-weight: 600; + background: var(--surface-muted); + color: var(--ink-950); + font-weight: 800; +} + +.hotkey-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.hotkey-grid h3 { + margin: 0 0 0.35rem; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-600); +} + +.privacy-banner { + position: fixed; + left: 1rem; + right: 1rem; + bottom: 1rem; + z-index: 35; + width: min(980px, calc(100% - 2rem)); + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border: 1px solid var(--line); + border-radius: 1rem; + padding: 1rem; + background: var(--surface-raised); + box-shadow: var(--shadow); +} + +.privacy-banner h2, +.privacy-banner p { + margin: 0; +} + +.privacy-banner h2 { + font-size: 1rem; +} + +.privacy-banner p { + margin-top: 0.35rem; + color: var(--ink-600); + max-width: 72ch; +} + +.privacy-banner__actions { + display: flex; + gap: 0.7rem; + flex-wrap: wrap; } .site-main { @@ -125,38 +283,31 @@ .site-footer { border-top: 1px solid var(--line); - background: color-mix(in srgb, var(--surface) 88%, white); + background: var(--surface); } .site-footer__inner { display: grid; - gap: 1.5rem; - padding: 2rem 0 3rem; -} - -.site-footer__title { - margin: 0 0 0.45rem; - font-weight: 700; + gap: 0.5rem; + padding: 2rem 0; } +.site-footer__title, .site-footer__copy, .site-footer__meta { margin: 0; - color: var(--ink-500); - max-width: 56ch; } -.site-footer__links { - display: flex; - flex-wrap: wrap; - gap: 0.85rem; +.site-footer__title { + font-weight: 800; } -.site-footer__links a { - color: var(--ink-700); +.site-footer__copy, +.site-footer__meta { + color: var(--ink-600); } -@media (max-width: 860px) { +@media (max-width: 980px) { .site-header__inner { flex-wrap: wrap; } @@ -164,18 +315,40 @@ .site-nav { order: 3; width: 100%; - justify-content: flex-start; + margin-left: 0; + } + + .site-actions { + margin-left: auto; } } -@media (max-width: 640px) { +@media (max-width: 720px) { .site-header__inner, .site-footer__inner { - width: min(1120px, calc(100% - 1.25rem)); + width: min(1180px, calc(100% - 1rem)); } - .header-cta { + .site-nav, + .site-actions { width: 100%; - text-align: center; + justify-content: flex-start; + } + + .menu-panel { + width: min(18rem, calc(100vw - 1rem)); + } + + .hotkey-grid, + .privacy-banner { + grid-template-columns: 1fr; + } + + .hotkey-grid { + display: grid; + } + + .privacy-banner { + display: grid; } } diff --git a/src/app/app.html b/src/app/app.html index f1d8f1b..8f2abb4 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -3,53 +3,142 @@
Navigation hotkeys
+