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 @@
-
- -
+ @if (hotkeysOpen()) { + + } + + @if (!consentService.consent().decided) { +
- -

Privacy controls

+

+ Analytics stays off until you opt in. If enabled, navigation is measured with pseudonymous + visitor and session ids, external referrers are reduced to hostnames, and sensitive query + params stay redacted.

- -
+ } - +
+ +
+ +
diff --git a/src/app/app.integration.spec.ts b/src/app/app.integration.spec.ts index b1b061f..a0ec5ef 100644 --- a/src/app/app.integration.spec.ts +++ b/src/app/app.integration.spec.ts @@ -9,6 +9,8 @@ import { provideDataAccess } from './core/providers/data.providers'; describe('App integration', () => { beforeEach(async () => { + window.localStorage.clear(); + await TestBed.configureTestingModule({ imports: [App], providers: [ @@ -26,35 +28,33 @@ describe('App integration', () => { fixture.detectChanges(); await router.navigateByUrl(url); - fixture.detectChanges(); await fixture.whenStable(); + fixture.detectChanges(); return fixture.nativeElement as HTMLElement; } - it('renders the shell navigation on the home route', async () => { + it('redirects to the localized home route and renders the Next Template shell', async () => { const element = await renderAppAt('/'); - expect(element.querySelector('.brand__name')?.textContent).toContain('Foundry Stack'); + expect(TestBed.inject(Router).url).toBe('/en'); + expect(element.querySelector('.brand__name')?.textContent).toContain('Next Template'); expect(element.querySelector('.skip-link')?.textContent).toContain('Skip to main content'); expect(element.querySelector('main h1')?.textContent).toContain( - 'Build once for GitHub Pages, then grow into backend services without rewriting the UI.', + 'One template, multiple production-ready starting points.', ); }); - it('loads a template detail route with related content and SEO metadata', async () => { - const element = await renderAppAt('/templates/atlas-launch-kit'); + it('loads a localized form example route', async () => { + const element = await renderAppAt('/en/examples/forms'); - expect(element.querySelector('main h1')?.textContent).toContain('Atlas Launch Kit'); - expect(element.querySelector('main')?.textContent).toContain('Northstar Ops'); - expect(document.title).toBe('Atlas Launch Kit | Foundry Stack'); + expect(element.querySelector('main h1')?.textContent).toContain('Employee profile form'); + expect(element.textContent).toContain('Reactive Forms'); }); - it('renders the not-found route for unknown paths', async () => { - const element = await renderAppAt('/does-not-exist'); + it('renders the localized not-found route for unknown paths', async () => { + const element = await renderAppAt('/de/does-not-exist'); - expect(element.querySelector('main h1')?.textContent).toContain( - 'That page does not exist in this build.', - ); + expect(element.querySelector('main h1')?.textContent).toContain('Die Seite existiert nicht.'); }); }); diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 53451d1..93d3aca 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -1,47 +1,45 @@ -import { PrerenderFallback, RenderMode, ServerRoute } from '@angular/ssr'; -import { SHOWCASES, TEMPLATES } from './shared/content/site.content'; +import { RenderMode, ServerRoute } from '@angular/ssr'; + +const localizedPaths = [ + 'en', + 'de', + 'en/about', + 'de/about', + 'en/login', + 'de/login', + 'en/register', + 'de/register', + 'en/examples/forms', + 'de/examples/forms', + 'en/examples/story', + 'de/examples/story', + 'en/examples/communication', + 'de/examples/communication', + 'en/examples/uploads', + 'de/examples/uploads', + 'en/table', + 'de/table', + 'en/remocn', + 'de/remocn', + 'en/blog', + 'de/blog', + 'en/changelog', + 'de/changelog', + 'en/report-problem', + 'de/report-problem', +]; + +const localizedServerRoutes: ServerRoute[] = localizedPaths.map((path) => ({ + path, + renderMode: RenderMode.Prerender, +})); export const serverRoutes: ServerRoute[] = [ { path: '', renderMode: RenderMode.Prerender, }, - { - path: 'templates', - renderMode: RenderMode.Prerender, - }, - { - path: 'templates/:slug', - renderMode: RenderMode.Prerender, - fallback: PrerenderFallback.Client, - async getPrerenderParams() { - return TEMPLATES.map(({ slug }) => ({ slug })); - }, - }, - { - path: 'showcase', - renderMode: RenderMode.Prerender, - }, - { - path: 'showcase/:slug', - renderMode: RenderMode.Prerender, - fallback: PrerenderFallback.Client, - async getPrerenderParams() { - return SHOWCASES.map(({ slug }) => ({ slug })); - }, - }, - { - path: 'docs', - renderMode: RenderMode.Prerender, - }, - { - path: 'about', - renderMode: RenderMode.Prerender, - }, - { - path: 'contact', - renderMode: RenderMode.Prerender, - }, + ...localizedServerRoutes, { path: '**', renderMode: RenderMode.Client, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index d6413f3..f98ac49 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,60 +1,99 @@ -import { Routes } from '@angular/router'; +import { inject } from '@angular/core'; +import { CanActivateFn, Router, Routes } from '@angular/router'; + +const localeGuard: CanActivateFn = (route) => { + const locale = route.paramMap.get('locale'); + return locale === 'en' || locale === 'de' ? true : inject(Router).parseUrl('/en'); +}; export const routes: Routes = [ { path: '', - title: 'Static-first template and showcase platform', - loadComponent: () => import('./features/home/home.page').then((module) => module.HomePageComponent), - }, - { - path: 'templates', - title: 'Templates', - loadComponent: () => - import('./features/templates/templates.page').then((module) => module.TemplatesPageComponent), - }, - { - path: 'templates/:slug', - title: 'Template detail', - loadComponent: () => - import('./features/templates/template-detail.page').then( - (module) => module.TemplateDetailPageComponent, - ), - }, - { - path: 'showcase', - title: 'Showcase', - loadComponent: () => - import('./features/showcase/showcase.page').then((module) => module.ShowcasePageComponent), - }, - { - path: 'showcase/:slug', - title: 'Showcase detail', - loadComponent: () => - import('./features/showcase/showcase-detail.page').then( - (module) => module.ShowcaseDetailPageComponent, - ), - }, - { - path: 'docs', - title: 'Documentation', - loadComponent: () => import('./features/docs/docs.page').then((module) => module.DocsPageComponent), - }, - { - path: 'about', - title: 'About', - loadComponent: () => - import('./features/about/about.page').then((module) => module.AboutPageComponent), + pathMatch: 'full', + redirectTo: 'en', }, { - path: 'contact', - title: 'Contact', - loadComponent: () => - import('./features/contact/contact.page').then((module) => module.ContactPageComponent), + path: ':locale', + canActivate: [localeGuard], + children: [ + { + path: '', + title: 'Next Template', + loadComponent: () => import('./features/next/home.page').then((module) => module.NextHomePageComponent), + }, + { + path: 'about', + title: 'About This Project', + loadComponent: () => import('./features/next/about.page').then((module) => module.NextAboutPageComponent), + }, + { + path: 'login', + title: 'Log in', + loadComponent: () => import('./features/next/login.page').then((module) => module.LoginPageComponent), + }, + { + path: 'register', + title: 'Register', + loadComponent: () => import('./features/next/register.page').then((module) => module.RegisterPageComponent), + }, + { + path: 'examples/forms', + title: 'Employee profile form', + loadComponent: () => + import('./features/next/form-example.page').then((module) => module.FormExamplePageComponent), + }, + { + path: 'examples/story', + title: 'Story Scroll Demo', + loadComponent: () => import('./features/next/story.page').then((module) => module.StoryPageComponent), + }, + { + path: 'examples/communication', + title: 'Realtime communication', + loadComponent: () => + import('./features/next/communication.page').then((module) => module.CommunicationPageComponent), + }, + { + path: 'examples/uploads', + title: 'Uploads', + loadComponent: () => import('./features/next/uploads.page').then((module) => module.UploadsPageComponent), + }, + { + path: 'table', + title: 'Employee table', + loadComponent: () => import('./features/next/table.page').then((module) => module.TablePageComponent), + }, + { + path: 'remocn', + title: 'remocn showcase', + loadComponent: () => import('./features/next/remocn.page').then((module) => module.RemocnPageComponent), + }, + { + path: 'blog', + title: 'Blog', + loadComponent: () => import('./features/next/blog.page').then((module) => module.BlogPageComponent), + }, + { + path: 'changelog', + title: 'Changelog', + loadComponent: () => import('./features/next/changelog.page').then((module) => module.ChangelogPageComponent), + }, + { + path: 'report-problem', + title: 'Report a problem', + loadComponent: () => + import('./features/next/report-problem.page').then((module) => module.ReportProblemPageComponent), + }, + { + path: '**', + title: 'Not found', + loadComponent: () => + import('./features/next/localized-not-found.page').then((module) => module.LocalizedNotFoundPageComponent), + }, + ], }, { path: '**', - title: 'Page not found', - loadComponent: () => - import('./features/docs/not-found.page').then((module) => module.NotFoundPageComponent), + redirectTo: 'en', }, ]; diff --git a/src/app/app.ts b/src/app/app.ts index accce08..9d0dee1 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,30 +1,86 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; - -interface NavItem { - readonly href: string; - readonly label: string; - readonly exact?: boolean; -} +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { Router, RouterLink, RouterOutlet } from '@angular/router'; +import { AppSettingsService } from './core/services/app-settings.service'; +import { ConsentService } from './core/services/consent.service'; +import { HotkeyService } from './core/services/hotkey.service'; +import { LocaleService } from './core/services/locale.service'; +import { NAVIGATION, NavigationEntry } from './shared/content/next-template.content'; +import { Locale } from './shared/models/content.models'; @Component({ selector: 'app-root', - imports: [RouterLink, RouterLinkActive, RouterOutlet], + imports: [RouterLink, RouterOutlet], templateUrl: './app.html', styleUrl: './app.css', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'app-root', + '(window:keydown)': 'handleKeydown($event)', }, }) export class App { - protected readonly navItems: readonly NavItem[] = [ - { href: '/', label: 'Home', exact: true }, - { href: '/templates', label: 'Templates' }, - { href: '/showcase', label: 'Showcase' }, - { href: '/docs', label: 'Docs' }, - { href: '/about', label: 'About' }, - ]; + private readonly router = inject(Router); + protected readonly localeService = inject(LocaleService); + protected readonly settingsService = inject(AppSettingsService); + protected readonly consentService = inject(ConsentService); + private readonly hotkeyService = inject(HotkeyService); + protected readonly locale = this.localeService.locale; + protected readonly groups = computed(() => NAVIGATION[this.locale()]); + protected readonly discoverGroup = computed(() => this.groups()[0]); + protected readonly workspaceGroup = computed(() => this.groups()[1]); + protected readonly accountGroup = computed(() => this.groups()[2]); + protected readonly openMenu = signal<'discover' | 'workspace' | null>(null); + protected readonly hotkeysOpen = signal(false); protected readonly currentYear = new Date().getFullYear(); + + protected localized(path: string): string { + return this.localeService.localizedPath(path); + } + + protected switchLocale(locale: Locale): string { + return this.localeService.currentPathFor(locale); + } + + protected toggleMenu(menu: 'discover' | 'workspace'): void { + this.openMenu.update((current) => (current === menu ? null : menu)); + } + + protected closeMenus(): void { + this.openMenu.set(null); + } + + protected toggleHotkeys(): void { + this.hotkeysOpen.update((open) => !open); + } + + protected closeHotkeys(): void { + this.hotkeysOpen.set(false); + } + + protected handleKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + this.closeMenus(); + this.closeHotkeys(); + return; + } + + if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + + const entry = this.hotkeyService.findEntry(this.groups(), event.key); + if (!entry) { + return; + } + + event.preventDefault(); + void this.navigateTo(entry); + } + + protected navigateTo(entry: NavigationEntry): void { + this.closeMenus(); + this.closeHotkeys(); + void this.router.navigateByUrl(this.localized(entry.path)); + } } diff --git a/src/app/core/providers/data.providers.ts b/src/app/core/providers/data.providers.ts index 5559c93..fbaab22 100644 --- a/src/app/core/providers/data.providers.ts +++ b/src/app/core/providers/data.providers.ts @@ -1,12 +1,24 @@ import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; import { + ApiAuthRepository, ApiContactRepository, + ApiEmployeeRepository, + ApiNewsletterRepository, + ApiProblemReportRepository, ApiShowcaseRepository, ApiTemplatesRepository, + AUTH_REPOSITORY, CONTACT_REPOSITORY, + EMPLOYEE_REPOSITORY, + NEWSLETTER_REPOSITORY, + PROBLEM_REPORT_REPOSITORY, SHOWCASE_REPOSITORY, + StaticAuthRepository, StaticContactRepository, + StaticEmployeeRepository, + StaticNewsletterRepository, + StaticProblemReportRepository, StaticShowcaseRepository, StaticTemplatesRepository, TEMPLATES_REPOSITORY, @@ -44,5 +56,41 @@ export function provideDataAccess(): EnvironmentProviders { ) => (environment.contentMode === 'api' ? apiRepository : staticRepository), deps: [APP_ENVIRONMENT, StaticContactRepository, ApiContactRepository], }, + { + provide: AUTH_REPOSITORY, + useFactory: ( + environment: AppEnvironment, + staticRepository: StaticAuthRepository, + apiRepository: ApiAuthRepository, + ) => (environment.contentMode === 'api' ? apiRepository : staticRepository), + deps: [APP_ENVIRONMENT, StaticAuthRepository, ApiAuthRepository], + }, + { + provide: EMPLOYEE_REPOSITORY, + useFactory: ( + environment: AppEnvironment, + staticRepository: StaticEmployeeRepository, + apiRepository: ApiEmployeeRepository, + ) => (environment.contentMode === 'api' ? apiRepository : staticRepository), + deps: [APP_ENVIRONMENT, StaticEmployeeRepository, ApiEmployeeRepository], + }, + { + provide: NEWSLETTER_REPOSITORY, + useFactory: ( + environment: AppEnvironment, + staticRepository: StaticNewsletterRepository, + apiRepository: ApiNewsletterRepository, + ) => (environment.contentMode === 'api' ? apiRepository : staticRepository), + deps: [APP_ENVIRONMENT, StaticNewsletterRepository, ApiNewsletterRepository], + }, + { + provide: PROBLEM_REPORT_REPOSITORY, + useFactory: ( + environment: AppEnvironment, + staticRepository: StaticProblemReportRepository, + apiRepository: ApiProblemReportRepository, + ) => (environment.contentMode === 'api' ? apiRepository : staticRepository), + deps: [APP_ENVIRONMENT, StaticProblemReportRepository, ApiProblemReportRepository], + }, ]); } diff --git a/src/app/core/providers/data.providers.unit.spec.ts b/src/app/core/providers/data.providers.unit.spec.ts index bdd80cd..1e74aed 100644 --- a/src/app/core/providers/data.providers.unit.spec.ts +++ b/src/app/core/providers/data.providers.unit.spec.ts @@ -2,12 +2,24 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { environment } from '../../../environments/environment'; import { + ApiAuthRepository, ApiContactRepository, + ApiEmployeeRepository, + ApiNewsletterRepository, + ApiProblemReportRepository, ApiShowcaseRepository, ApiTemplatesRepository, + AUTH_REPOSITORY, CONTACT_REPOSITORY, + EMPLOYEE_REPOSITORY, + NEWSLETTER_REPOSITORY, + PROBLEM_REPORT_REPOSITORY, SHOWCASE_REPOSITORY, + StaticAuthRepository, StaticContactRepository, + StaticEmployeeRepository, + StaticNewsletterRepository, + StaticProblemReportRepository, StaticShowcaseRepository, StaticTemplatesRepository, TEMPLATES_REPOSITORY, @@ -28,6 +40,10 @@ describe('provideDataAccess unit', () => { expect(TestBed.inject(TEMPLATES_REPOSITORY)).toBeInstanceOf(StaticTemplatesRepository); expect(TestBed.inject(SHOWCASE_REPOSITORY)).toBeInstanceOf(StaticShowcaseRepository); expect(TestBed.inject(CONTACT_REPOSITORY)).toBeInstanceOf(StaticContactRepository); + expect(TestBed.inject(AUTH_REPOSITORY)).toBeInstanceOf(StaticAuthRepository); + expect(TestBed.inject(EMPLOYEE_REPOSITORY)).toBeInstanceOf(StaticEmployeeRepository); + expect(TestBed.inject(NEWSLETTER_REPOSITORY)).toBeInstanceOf(StaticNewsletterRepository); + expect(TestBed.inject(PROBLEM_REPORT_REPOSITORY)).toBeInstanceOf(StaticProblemReportRepository); }); it('wires API repositories for the connected build mode', () => { @@ -50,5 +66,9 @@ describe('provideDataAccess unit', () => { expect(TestBed.inject(TEMPLATES_REPOSITORY)).toBeInstanceOf(ApiTemplatesRepository); expect(TestBed.inject(SHOWCASE_REPOSITORY)).toBeInstanceOf(ApiShowcaseRepository); expect(TestBed.inject(CONTACT_REPOSITORY)).toBeInstanceOf(ApiContactRepository); + expect(TestBed.inject(AUTH_REPOSITORY)).toBeInstanceOf(ApiAuthRepository); + expect(TestBed.inject(EMPLOYEE_REPOSITORY)).toBeInstanceOf(ApiEmployeeRepository); + expect(TestBed.inject(NEWSLETTER_REPOSITORY)).toBeInstanceOf(ApiNewsletterRepository); + expect(TestBed.inject(PROBLEM_REPORT_REPOSITORY)).toBeInstanceOf(ApiProblemReportRepository); }); }); diff --git a/src/app/core/services/app-settings.service.ts b/src/app/core/services/app-settings.service.ts new file mode 100644 index 0000000..147655f --- /dev/null +++ b/src/app/core/services/app-settings.service.ts @@ -0,0 +1,125 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { Injectable, PLATFORM_ID, computed, inject, signal } from '@angular/core'; +import { AppSettings } from '../../shared/models/content.models'; + +const STORAGE_KEY = 'app-settings'; + +export const DEFAULT_APP_SETTINGS: AppSettings = { + theme: 'light', + background: 'paper', + dateFormat: 'localized', + weekStartsOn: 1, + showOutsideDays: true, + compactSpacing: false, + reducedMotion: false, + showHotkeyHints: true, + notifications: { + enabled: true, + type: 'instant', + }, +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function readSettings(value: string | null): AppSettings | null { + if (!value) { + return null; + } + + try { + const parsed: unknown = JSON.parse(value); + if (!isRecord(parsed)) { + return null; + } + + return { + ...DEFAULT_APP_SETTINGS, + ...parsed, + theme: parsed['theme'] === 'dark' ? 'dark' : 'light', + notifications: { + ...DEFAULT_APP_SETTINGS.notifications, + ...(isRecord(parsed['notifications']) ? parsed['notifications'] : {}), + }, + }; + } catch { + return null; + } +} + +@Injectable({ + providedIn: 'root', +}) +export class AppSettingsService { + private readonly document = inject(DOCUMENT); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + private readonly settingsState = signal(this.loadInitialSettings()); + + readonly settings = this.settingsState.asReadonly(); + readonly isDark = computed(() => this.settings().theme === 'dark'); + + constructor() { + this.apply(this.settings()); + } + + toggleTheme(): void { + this.update({ + theme: this.settings().theme === 'dark' ? 'light' : 'dark', + }); + } + + update(partial: Partial): void { + this.settingsState.update((settings) => { + const next: AppSettings = { + ...settings, + ...partial, + notifications: { + ...settings.notifications, + ...(partial.notifications ?? {}), + }, + }; + this.persist(next); + this.apply(next); + return next; + }); + } + + private loadInitialSettings(): AppSettings { + if (!this.isBrowser) { + return DEFAULT_APP_SETTINGS; + } + + const stored = readSettings(window.localStorage.getItem(STORAGE_KEY)); + if (stored) { + return stored; + } + + const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; + return { + ...DEFAULT_APP_SETTINGS, + theme: prefersDark ? 'dark' : 'light', + }; + } + + private persist(settings: AppSettings): void { + if (!this.isBrowser) { + return; + } + + const value = JSON.stringify(settings); + window.localStorage.setItem(STORAGE_KEY, value); + this.document.cookie = `${STORAGE_KEY}=${encodeURIComponent(value)}; path=/; max-age=31536000; SameSite=Lax`; + this.document.cookie = `theme=${settings.theme}; path=/; max-age=31536000; SameSite=Lax`; + } + + private apply(settings: AppSettings): void { + const root = this.document.documentElement; + root.classList.toggle('dark', settings.theme === 'dark'); + root.classList.toggle('light', settings.theme === 'light'); + root.dataset['background'] = settings.background; + root.dataset['density'] = settings.compactSpacing ? 'compact' : 'comfortable'; + root.dataset['motion'] = settings.reducedMotion ? 'reduced' : 'full'; + root.dataset['hotkeyHints'] = settings.showHotkeyHints ? 'visible' : 'hidden'; + } +} diff --git a/src/app/core/services/app-template-services.unit.spec.ts b/src/app/core/services/app-template-services.unit.spec.ts new file mode 100644 index 0000000..324e527 --- /dev/null +++ b/src/app/core/services/app-template-services.unit.spec.ts @@ -0,0 +1,53 @@ +import { TestBed } from '@angular/core/testing'; +import { NAVIGATION } from '../../shared/content/next-template.content'; +import { AppSettingsService } from './app-settings.service'; +import { ConsentService } from './consent.service'; +import { HotkeyService } from './hotkey.service'; +import { UploadClassifierService } from './upload-classifier.service'; + +describe('App template services unit', () => { + beforeEach(() => { + window.localStorage.clear(); + document.documentElement.className = ''; + TestBed.configureTestingModule({}); + }); + + it('classifies upload items by MIME type and extension', () => { + const service = TestBed.inject(UploadClassifierService); + const image = new File(['x'], 'preview.png', { type: 'image/png' }); + const data = new File(['{}'], 'records.json', { type: 'application/octet-stream' }); + + expect(service.classify(image).kind).toBe('Image'); + expect(service.classify(image).strategy).toBe('Preview and optimize'); + expect(service.classify(data).kind).toBe('Data file'); + expect(service.classify(data).strategy).toBe('Validate schema'); + }); + + it('persists theme settings and applies document classes', () => { + const service = TestBed.inject(AppSettingsService); + + service.update({ theme: 'dark', compactSpacing: true, reducedMotion: true }); + + expect(service.settings().theme).toBe('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + expect(document.documentElement.dataset['density']).toBe('compact'); + expect(document.documentElement.dataset['motion']).toBe('reduced'); + expect(window.localStorage.getItem('app-settings')).toContain('"theme":"dark"'); + }); + + it('stores analytics consent decisions', () => { + const service = TestBed.inject(ConsentService); + + service.acceptAll(); + + expect(service.consent()).toEqual({ decided: true, analytics: true }); + expect(window.localStorage.getItem('app-consent')).toContain('"analytics":true'); + }); + + it('resolves navigation entries from Alt hotkeys', () => { + const service = TestBed.inject(HotkeyService); + + expect(service.findEntry(NAVIGATION.en, 'f')?.path).toBe('examples/forms'); + expect(service.findEntry(NAVIGATION.en, 'x')).toBeUndefined(); + }); +}); diff --git a/src/app/core/services/consent.service.ts b/src/app/core/services/consent.service.ts new file mode 100644 index 0000000..41e148b --- /dev/null +++ b/src/app/core/services/consent.service.ts @@ -0,0 +1,47 @@ +import { isPlatformBrowser } from '@angular/common'; +import { Injectable, PLATFORM_ID, inject, signal } from '@angular/core'; +import { ConsentState } from '../../shared/models/content.models'; + +const STORAGE_KEY = 'app-consent'; + +@Injectable({ + providedIn: 'root', +}) +export class ConsentService { + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + private readonly state = signal(this.load()); + + readonly consent = this.state.asReadonly(); + + acceptAll(): void { + this.set({ decided: true, analytics: true }); + } + + necessaryOnly(): void { + this.set({ decided: true, analytics: false }); + } + + private set(state: ConsentState): void { + this.state.set(state); + if (this.isBrowser) { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } + } + + private load(): ConsentState { + if (!this.isBrowser) { + return { decided: true, analytics: false }; + } + + try { + const stored: unknown = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null'); + if (stored && typeof stored === 'object' && 'decided' in stored && 'analytics' in stored) { + return stored as ConsentState; + } + } catch { + return { decided: false, analytics: false }; + } + + return { decided: false, analytics: false }; + } +} diff --git a/src/app/core/services/hotkey.service.ts b/src/app/core/services/hotkey.service.ts new file mode 100644 index 0000000..932fca1 --- /dev/null +++ b/src/app/core/services/hotkey.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@angular/core'; +import { NavigationEntry, NavigationGroup } from '../../shared/content/next-template.content'; + +@Injectable({ + providedIn: 'root', +}) +export class HotkeyService { + findEntry(groups: readonly NavigationGroup[], key: string): NavigationEntry | undefined { + const normalizedKey = key.toUpperCase(); + return groups.flatMap((group) => group.entries).find((entry) => entry.hotkey === normalizedKey); + } +} diff --git a/src/app/core/services/locale.service.ts b/src/app/core/services/locale.service.ts new file mode 100644 index 0000000..63e0c49 --- /dev/null +++ b/src/app/core/services/locale.service.ts @@ -0,0 +1,38 @@ +import { computed, Injectable, inject } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { filter, map } from 'rxjs'; +import { Locale } from '../../shared/models/content.models'; + +function normalizeLocale(value: string | undefined): Locale { + return value === 'de' ? 'de' : 'en'; +} + +@Injectable({ + providedIn: 'root', +}) +export class LocaleService { + private readonly router = inject(Router); + private readonly url = toSignal( + this.router.events.pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + map((event) => event.urlAfterRedirects), + ), + { initialValue: this.router.url }, + ); + + readonly locale = computed(() => normalizeLocale(this.url().split('/').filter(Boolean)[0])); + readonly relativePath = computed(() => { + const segments = this.url().split('?')[0].split('/').filter(Boolean); + return segments.length > 1 ? segments.slice(1).join('/') : ''; + }); + + localizedPath(path: string, locale = this.locale()): string { + const cleanPath = path.replace(/^\/+|\/+$/g, ''); + return cleanPath.length > 0 ? `/${locale}/${cleanPath}` : `/${locale}`; + } + + currentPathFor(locale: Locale): string { + return this.localizedPath(this.relativePath(), locale); + } +} diff --git a/src/app/core/services/upload-classifier.service.ts b/src/app/core/services/upload-classifier.service.ts new file mode 100644 index 0000000..f0a0609 --- /dev/null +++ b/src/app/core/services/upload-classifier.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { UploadKind, UploadQueueItem, UploadStrategy } from '../../shared/models/content.models'; + +function extensionFor(name: string): string { + return name.split('.').pop()?.toLowerCase() ?? ''; +} + +@Injectable({ + providedIn: 'root', +}) +export class UploadClassifierService { + classify(file: File): UploadQueueItem { + const kind = this.kindFor(file); + return { + id: `${file.name}-${file.size}-${file.lastModified}`, + name: file.name, + size: file.size, + type: file.type || 'unknown', + kind, + strategy: this.strategyFor(kind), + }; + } + + private kindFor(file: File): UploadKind { + const extension = extensionFor(file.name); + + if (file.type.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(extension)) { + return 'Image'; + } + + if (file.type.startsWith('video/') || file.type.startsWith('audio/') || ['mp4', 'mov', 'mp3', 'wav'].includes(extension)) { + return 'Media'; + } + + if (['csv', 'json', 'xml', 'parquet'].includes(extension)) { + return 'Data file'; + } + + if (['pdf', 'doc', 'docx', 'txt', 'md'].includes(extension)) { + return 'Document'; + } + + return 'Other'; + } + + private strategyFor(kind: UploadKind): UploadStrategy { + switch (kind) { + case 'Image': + return 'Preview and optimize'; + case 'Document': + return 'Scan and store'; + case 'Media': + return 'Transcode or chunk'; + case 'Data file': + return 'Validate schema'; + case 'Other': + return 'Manual review'; + } + } +} diff --git a/src/app/features/next/about.page.ts b/src/app/features/next/about.page.ts new file mode 100644 index 0000000..57b8c45 --- /dev/null +++ b/src/app/features/next/about.page.ts @@ -0,0 +1,43 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { SeoService } from '../../core/services/seo.service'; +import { LocaleService } from '../../core/services/locale.service'; +import { ABOUT_COPY } from '../../shared/content/next-template.content'; + +@Component({ + selector: 'app-next-about-page', + template: ` +
+
+

{{ copy().eyebrow }}

+

{{ copy().title }}

+

{{ copy().lead }}

+
+ +
+
+

Locale routing

+

Routes like /en/about and /de/about share components while reading localized content.

+
+
+

Typed content

+

Page copy, posts, changelog entries, employees, and demo records use stable TypeScript models.

+
+
+

Backend optional

+

Repositories keep static hosting viable while connected and server builds can use API endpoints.

+
+
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NextAboutPageComponent { + private readonly seo = inject(SeoService); + private readonly localeService = inject(LocaleService); + protected readonly copy = computed(() => ABOUT_COPY[this.localeService.locale()]); + + constructor() { + this.seo.setPage('About This Project', 'Locale routing and typed feature routes for the Angular template.'); + } +} diff --git a/src/app/features/next/blog.page.ts b/src/app/features/next/blog.page.ts new file mode 100644 index 0000000..fe27597 --- /dev/null +++ b/src/app/features/next/blog.page.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { LocaleService } from '../../core/services/locale.service'; +import { SeoService } from '../../core/services/seo.service'; +import { BLOG_POSTS } from '../../shared/content/next-template.content'; + +@Component({ + selector: 'app-blog-page', + template: ` +
+
+

Blog

+

{{ locale() === 'de' ? 'Repo-verwaltete Site-Inhalte' : 'Repo-managed site content' }}

+

+ {{ locale() === 'de' ? 'Kanonische Produkt- und Marketing-Inhalte leben typisiert im Repository.' : 'Canonical product and marketing content lives in typed source files under version control.' }} +

+
+ +
+ @for (post of posts(); track post.title) { +
+ +

{{ post.title }}

+

{{ post.summary }}

+
+ } +
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BlogPageComponent { + private readonly localeService = inject(LocaleService); + private readonly seo = inject(SeoService); + protected readonly locale = this.localeService.locale; + protected readonly posts = computed(() => BLOG_POSTS[this.locale()]); + + constructor() { + this.seo.setPage('Blog', 'Repo-managed localized content for the Angular template.'); + } +} diff --git a/src/app/features/next/changelog.page.ts b/src/app/features/next/changelog.page.ts new file mode 100644 index 0000000..3046fb3 --- /dev/null +++ b/src/app/features/next/changelog.page.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { LocaleService } from '../../core/services/locale.service'; +import { SeoService } from '../../core/services/seo.service'; +import { CHANGELOG_ENTRIES } from '../../shared/content/next-template.content'; + +@Component({ + selector: 'app-changelog-page', + template: ` +
+
+

Changelog

+

{{ locale() === 'de' ? 'Operative Aenderungen und Plattform-Updates' : 'Operational changes and platform updates' }}

+
+ +
+ @for (entry of entries(); track entry.title) { +
+ +

{{ entry.title }}

+

{{ entry.summary }}

+
+ } +
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChangelogPageComponent { + private readonly localeService = inject(LocaleService); + private readonly seo = inject(SeoService); + protected readonly locale = this.localeService.locale; + protected readonly entries = computed(() => CHANGELOG_ENTRIES[this.locale()]); + + constructor() { + this.seo.setPage('Changelog', 'Operational changes and platform updates.'); + } +} diff --git a/src/app/features/next/communication.page.ts b/src/app/features/next/communication.page.ts new file mode 100644 index 0000000..73c7a95 --- /dev/null +++ b/src/app/features/next/communication.page.ts @@ -0,0 +1,108 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { finalize } from 'rxjs'; +import { LocaleService } from '../../core/services/locale.service'; +import { SeoService } from '../../core/services/seo.service'; +import { NEWSLETTER_REPOSITORY } from '../../shared/data-access/content.repositories'; + +@Component({ + selector: 'app-communication-page', + imports: [ReactiveFormsModule], + template: ` +
+
+

{{ locale() === 'de' ? 'Kommunikationskategorie' : 'Communication category' }}

+

{{ locale() === 'de' ? 'Echtzeitkommunikation' : 'Realtime communication' }}

+

+ {{ locale() === 'de' ? 'Eine kurze Referenz fuer Kollaborationsbausteine, sobald mehrere Clients denselben Zustand teilen muessen.' : 'A quick reference for collaboration primitives once multiple clients must share state.' }} +

+
+ +
+ + +
+
+

{{ locale() === 'de' ? 'Kommunikationsthema' : 'Communication topic' }}

+

WebSockets

+

WebSockets keep one long-lived connection open so clients and servers can exchange low-latency events without repeated request setup.

+
    +
  • Useful for presence, chat, collaborative cursors, and streaming updates.
  • +
  • Most apps need auth refresh, heartbeats, reconnect state, and backoff.
  • +
  • Small event payloads are easier to merge incrementally in the UI.
  • +
+
+ +
+

{{ locale() === 'de' ? 'Kommunikationsthema' : 'Communication topic' }}

+

CRDTs

+

CRDTs let multiple clients edit shared state concurrently and still converge without central lock-step coordination.

+
    +
  • Useful when collaboration must survive offline periods.
  • +
  • Operations are often stored locally and synced later.
  • +
  • Conflict resolution moves into the data structure.
  • +
+
+
+
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CommunicationPageComponent { + private readonly formBuilder = inject(NonNullableFormBuilder); + private readonly newsletterRepository = inject(NEWSLETTER_REPOSITORY); + private readonly localeService = inject(LocaleService); + private readonly seo = inject(SeoService); + protected readonly locale = this.localeService.locale; + protected readonly form = this.formBuilder.group({ + email: ['', [Validators.required, Validators.email]], + }); + protected readonly submitting = signal(false); + protected readonly message = signal(''); + protected readonly hasError = signal(false); + + constructor() { + this.seo.setPage('Realtime communication', 'Newsletter, WebSocket, and CRDT communication scaffolding.'); + } + + protected subscribe(): void { + this.message.set(''); + this.hasError.set(false); + + if (this.form.invalid) { + this.form.markAllAsTouched(); + this.hasError.set(true); + this.message.set('Enter a valid email address.'); + return; + } + + this.submitting.set(true); + this.form.disable(); + this.newsletterRepository + .subscribe(this.form.getRawValue()) + .pipe(finalize(() => { + this.submitting.set(false); + this.form.enable(); + })) + .subscribe((result) => { + this.hasError.set(!result.ok); + this.message.set(result.message); + }); + } +} diff --git a/src/app/features/next/form-example.page.ts b/src/app/features/next/form-example.page.ts new file mode 100644 index 0000000..08943d7 --- /dev/null +++ b/src/app/features/next/form-example.page.ts @@ -0,0 +1,153 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { LocaleService } from '../../core/services/locale.service'; +import { SeoService } from '../../core/services/seo.service'; +import { FORM_STATE_NOTES } from '../../shared/content/next-template.content'; + +@Component({ + selector: 'app-form-example-page', + imports: [ReactiveFormsModule], + template: ` +
+
+

{{ locale() === 'de' ? 'Formularbeispiel' : 'Form example' }}

+

{{ locale() === 'de' ? 'Mitarbeiterprofil-Formular' : 'Employee profile form' }}

+

+ {{ locale() === 'de' ? 'Ein produktionsnahes Reactive-Forms-Beispiel mit zehn Feldern und einer kompakten Zustandsreferenz.' : 'A production-leaning Reactive Forms example with ten fields and a quick state-management reference.' }} +

+
+ +
+ @for (note of notes(); track note.eyebrow) { +
+

{{ note.eyebrow }}

+

{{ note.title }}

+

{{ note.lead }}

+
+ } +
+ +
+

{{ locale() === 'de' ? 'Mitarbeiterprofil-Formular' : 'Employee profile form' }}

+
+ + + + + + + + + + +
+ + @if (form.invalid && submitted()) { +

Complete required fields with valid values.

+ } + + @if (message()) { +

{{ message() }}

+ } + +
+ + +
+
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FormExamplePageComponent { + private readonly formBuilder = inject(NonNullableFormBuilder); + private readonly localeService = inject(LocaleService); + private readonly seo = inject(SeoService); + protected readonly locale = this.localeService.locale; + protected readonly notes = computed(() => FORM_STATE_NOTES[this.locale()]); + protected readonly submitted = signal(false); + protected readonly message = signal(''); + protected readonly departments = ['Engineering', 'Product', 'Design', 'Marketing', 'Sales', 'People Ops']; + protected readonly form = this.formBuilder.group({ + firstName: ['Alex', Validators.required], + lastName: ['Johnson', Validators.required], + email: ['alex@example.com', [Validators.required, Validators.email]], + phone: ['+49 30 123456'], + age: [34, [Validators.required, Validators.min(18)]], + jobTitle: ['Frontend Lead', Validators.required], + startDate: ['2026-04-20', Validators.required], + department: ['Engineering', Validators.required], + newsletter: [true], + bio: ['Builds accessible Angular application foundations.', [Validators.required, Validators.minLength(20)]], + }); + + constructor() { + this.seo.setPage('Employee profile form', 'Reactive Forms example with validation and state notes.'); + } + + protected submit(): void { + this.submitted.set(true); + this.message.set(''); + + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + this.message.set('Profile submitted locally. The form is ready to connect to an API repository.'); + } + + protected reset(): void { + this.submitted.set(false); + this.message.set(''); + this.form.reset({ + firstName: 'Alex', + lastName: 'Johnson', + email: 'alex@example.com', + phone: '+49 30 123456', + age: 34, + jobTitle: 'Frontend Lead', + startDate: '2026-04-20', + department: 'Engineering', + newsletter: true, + bio: 'Builds accessible Angular application foundations.', + }); + } +} diff --git a/src/app/features/next/home.page.ts b/src/app/features/next/home.page.ts new file mode 100644 index 0000000..7de68ca --- /dev/null +++ b/src/app/features/next/home.page.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { SeoService } from '../../core/services/seo.service'; +import { LocaleService } from '../../core/services/locale.service'; +import { HOME_COPY, HOME_FEATURES } from '../../shared/content/next-template.content'; + +@Component({ + selector: 'app-next-home-page', + imports: [RouterLink], + template: ` + + `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NextHomePageComponent { + protected readonly localeService = inject(LocaleService); + private readonly seo = inject(SeoService); + protected readonly locale = this.localeService.locale; + protected readonly copy = computed(() => HOME_COPY[this.locale()]); + protected readonly features = computed(() => HOME_FEATURES[this.locale()]); + + constructor() { + this.seo.setPage( + 'Next Template', + 'Localized Angular application foundation with auth, forms, data views, storytelling, communication notes, and upload scaffolding.', + ); + } +} diff --git a/src/app/features/next/localized-not-found.page.ts b/src/app/features/next/localized-not-found.page.ts new file mode 100644 index 0000000..8be514d --- /dev/null +++ b/src/app/features/next/localized-not-found.page.ts @@ -0,0 +1,34 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { LocaleService } from '../../core/services/locale.service'; +import { SeoService } from '../../core/services/seo.service'; + +@Component({ + selector: 'app-localized-not-found-page', + imports: [RouterLink], + template: ` +
+
+

Not found

+

{{ locale() === 'de' ? 'Die Seite existiert nicht.' : 'The page does not exist.' }}

+

+ {{ locale() === 'de' ? 'Nutze die Navigation oder kehre zur lokalisierten Startseite zurueck.' : 'Use the main navigation or return to the localized home page.' }} +

+ + {{ locale() === 'de' ? 'Zur Startseite' : 'Go home' }} + +
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LocalizedNotFoundPageComponent { + protected readonly localeService = inject(LocaleService); + private readonly seo = inject(SeoService); + protected readonly locale = this.localeService.locale; + + constructor() { + this.seo.setPage('Not found', 'The requested localized route does not exist.'); + } +} diff --git a/src/app/features/next/login.page.ts b/src/app/features/next/login.page.ts new file mode 100644 index 0000000..97490bd --- /dev/null +++ b/src/app/features/next/login.page.ts @@ -0,0 +1,109 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { finalize } from 'rxjs'; +import { LocaleService } from '../../core/services/locale.service'; +import { SeoService } from '../../core/services/seo.service'; +import { AUTH_REPOSITORY } from '../../shared/data-access/content.repositories'; + +@Component({ + selector: 'app-login-page', + imports: [ReactiveFormsModule, RouterLink], + template: ` +
+
+

{{ locale() === 'de' ? 'Willkommen zurueck' : 'Welcome back' }}

+

{{ locale() === 'de' ? 'Melde dich an und arbeite dort weiter, wo du zuletzt aufgehoert hast.' : 'Sign in to continue where you left off.' }}

+

+ {{ locale() === 'de' ? 'Greife mit deiner E-Mail-Adresse und deinem Passwort auf geschuetzte Bereiche zu.' : 'Access protected tools with your email and password.' }} +

+
+ +
+
+

{{ locale() === 'de' ? 'Anmelden' : 'Log in' }}

+

{{ locale() === 'de' ? 'Nutze deine Zugangsdaten, um auf die Anwendung zuzugreifen.' : 'Use your account credentials to access the application.' }}

+
+ +
+ + + + + + + @if (message()) { +

{{ message() }}

+ } + +

+ {{ locale() === 'de' ? 'Noch kein Konto?' : 'Need an account?' }} + {{ locale() === 'de' ? 'Registrieren' : 'Create one' }} +

+
+
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LoginPageComponent { + private readonly formBuilder = inject(NonNullableFormBuilder); + private readonly authRepository = inject(AUTH_REPOSITORY); + private readonly seo = inject(SeoService); + protected readonly localeService = inject(LocaleService); + protected readonly locale = this.localeService.locale; + protected readonly submitting = signal(false); + protected readonly message = signal(''); + protected readonly hasSubmissionError = signal(false); + protected readonly form = this.formBuilder.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', Validators.required], + }); + + constructor() { + this.seo.setPage('Log in', 'Sign in to the localized Angular template application.'); + } + + protected hasError(controlName: 'email' | 'password'): boolean { + const control = this.form.controls[controlName]; + return control.invalid && control.touched; + } + + protected submit(): void { + this.message.set(''); + this.hasSubmissionError.set(false); + + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + this.submitting.set(true); + this.form.disable(); + this.authRepository + .login(this.form.getRawValue()) + .pipe(finalize(() => { + this.submitting.set(false); + this.form.enable(); + })) + .subscribe((result) => { + this.hasSubmissionError.set(!result.ok); + this.message.set(result.message); + }); + } +} diff --git a/src/app/features/next/next-pages.css b/src/app/features/next/next-pages.css new file mode 100644 index 0000000..894ed8a --- /dev/null +++ b/src/app/features/next/next-pages.css @@ -0,0 +1,169 @@ +.hero-actions, +.example-links, +.form-actions, +.story-controls, +.upload-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.feature-card h2, +.content-card h2, +.content-card h3 { + margin-top: 0; +} + +.auth-layout, +.split-layout { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: 1rem; + align-items: start; +} + +.auth-form, +.plain-form { + display: grid; + gap: 1rem; +} + +.story-shell { + display: grid; + grid-template-columns: 16rem minmax(0, 1fr); + gap: 1rem; +} + +.story-minimap { + display: grid; + gap: 0.5rem; +} + +.story-minimap button { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.75rem; + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + padding: 0.75rem; + background: var(--surface-raised); + color: var(--ink-900); + text-align: left; +} + +.story-minimap button.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--surface-raised)); +} + +.story-stage { + min-height: 24rem; + display: grid; + align-content: center; + gap: 1rem; + overflow: hidden; +} + +.story-orbit { + inline-size: min(24rem, 100%); + aspect-ratio: 1; + border: 1px solid var(--line); + border-radius: 50%; + background: + linear-gradient(120deg, transparent 48%, color-mix(in srgb, var(--accent) 35%, transparent) 49% 51%, transparent 52%), + radial-gradient(circle at 35% 35%, color-mix(in srgb, var(--accent) 28%, transparent), transparent 30%), + var(--surface-muted); +} + +:root[data-motion='full'] .story-orbit { + animation: story-spin 18s linear infinite; +} + +@keyframes story-spin { + to { + transform: rotate(360deg); + } +} + +.newsletter-card { + display: grid; + gap: 1rem; +} + +.drop-zone { + display: grid; + place-items: center; + min-height: 12rem; + border: 2px dashed var(--line-strong); + border-radius: 8px; + padding: 1.25rem; + text-align: center; + background: var(--surface-muted); +} + +.queue-list { + display: grid; + gap: 0.75rem; +} + +.queue-item { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.75rem; + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + padding: 0.85rem; +} + +.table-wrap { + overflow-x: auto; +} + +.remocn-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.terminal { + background: #111827; + color: #d1fae5; +} + +.decode { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 1.8rem; + word-break: break-word; +} + +.spotlight { + background: + radial-gradient(circle at 70% 35%, color-mix(in srgb, var(--accent) 28%, transparent), transparent 34%), + var(--surface-raised); +} + +.post-list { + display: grid; + gap: 1rem; +} + +.post-list article { + display: grid; + gap: 0.35rem; +} + +.post-list time { + color: var(--ink-600); + font-weight: 800; +} + +@media (max-width: 860px) { + .auth-layout, + .split-layout, + .story-shell, + .remocn-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/app/features/next/register.page.ts b/src/app/features/next/register.page.ts new file mode 100644 index 0000000..3a6aa2f --- /dev/null +++ b/src/app/features/next/register.page.ts @@ -0,0 +1,156 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { finalize } from 'rxjs'; +import { LocaleService } from '../../core/services/locale.service'; +import { SeoService } from '../../core/services/seo.service'; +import { AUTH_REPOSITORY } from '../../shared/data-access/content.repositories'; + +@Component({ + selector: 'app-register-page', + imports: [ReactiveFormsModule, RouterLink], + template: ` +
+
+

{{ locale() === 'de' ? 'Konto erstellen' : 'Create your account' }}

+

{{ locale() === 'de' ? 'Erstelle ein sicheres Konto und lege sofort los.' : 'Start with a secure account and get into the app immediately.' }}

+

+ {{ locale() === 'de' ? 'Registriere dich mit einer gueltigen E-Mail-Adresse und einem starken Passwort.' : 'Register with a valid email and a strong password.' }} +

+
+ +
+
+

{{ locale() === 'de' ? 'Registrieren' : 'Register' }}

+ + + + + + + + + + + + @if (registrationMessage()) { +

{{ registrationMessage() }}

+ } + +

+ {{ locale() === 'de' ? 'Bereits registriert?' : 'Already have an account?' }} + {{ locale() === 'de' ? 'Anmelden' : 'Log in' }} +

+
+ +
+

{{ locale() === 'de' ? 'Passwort zuruecksetzen?' : 'Need to reset an existing password?' }}

+

{{ locale() === 'de' ? 'Sende einen sicheren Reset-Link, ohne diese Seite zu verlassen.' : 'Send a secure reset link without leaving this page.' }}

+ + + + + + @if (resetMessage()) { +

{{ resetMessage() }}

+ } +
+
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegisterPageComponent { + private readonly formBuilder = inject(NonNullableFormBuilder); + private readonly authRepository = inject(AUTH_REPOSITORY); + private readonly seo = inject(SeoService); + protected readonly localeService = inject(LocaleService); + protected readonly locale = this.localeService.locale; + protected readonly submitting = signal(false); + protected readonly resetSubmitting = signal(false); + protected readonly registrationMessage = signal(''); + protected readonly resetMessage = signal(''); + protected readonly registrationHasError = signal(false); + protected readonly resetHasError = signal(false); + protected readonly form = this.formBuilder.group({ + name: ['', Validators.required], + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(8)]], + confirmPassword: ['', Validators.required], + }); + protected readonly resetForm = this.formBuilder.group({ + email: ['', [Validators.required, Validators.email]], + }); + + constructor() { + this.seo.setPage('Register', 'Create an account or request a password reset.'); + } + + protected submitRegistration(): void { + this.registrationMessage.set(''); + this.registrationHasError.set(false); + + if (this.form.invalid || this.form.controls.password.value !== this.form.controls.confirmPassword.value) { + this.form.markAllAsTouched(); + this.registrationHasError.set(true); + this.registrationMessage.set('Use a valid email and matching passwords of at least 8 characters.'); + return; + } + + this.submitting.set(true); + this.form.disable(); + this.authRepository + .register(this.form.getRawValue()) + .pipe(finalize(() => { + this.submitting.set(false); + this.form.enable(); + })) + .subscribe((result) => { + this.registrationHasError.set(!result.ok); + this.registrationMessage.set(result.message); + }); + } + + protected submitReset(): void { + this.resetMessage.set(''); + this.resetHasError.set(false); + + if (this.resetForm.invalid) { + this.resetForm.markAllAsTouched(); + return; + } + + this.resetSubmitting.set(true); + this.resetForm.disable(); + this.authRepository + .requestPasswordReset(this.resetForm.getRawValue()) + .pipe(finalize(() => { + this.resetSubmitting.set(false); + this.resetForm.enable(); + })) + .subscribe((result) => { + this.resetHasError.set(!result.ok); + this.resetMessage.set(result.message); + }); + } +} diff --git a/src/app/features/next/remocn.page.ts b/src/app/features/next/remocn.page.ts new file mode 100644 index 0000000..dc37830 --- /dev/null +++ b/src/app/features/next/remocn.page.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { SeoService } from '../../core/services/seo.service'; + +@Component({ + selector: 'app-remocn-page', + template: ` +
+
+

Registry components

+

A remocn showcase route inside the existing app shell.

+

+ This Angular route mirrors the registry showcase with browser-safe motion treatments and reusable preview blocks. +

+ +
+ +
+
+

UI block

+

TerminalSimulator

+
~/projects/remocn-showcase
+bunx shadcn add @remocn/blur-reveal
+registry installed: 4 components
+
+ +
+

Typography

+

BlurReveal

+

A heavy-to-sharp text reveal that works well for hero copy, intros, and product names.

+
+ +
+

Typography

+

MatrixDecode

+

RE-%@?(% =$-_\\

+
+ +
+

Environment and lighting

+

SpotlightCard

+

A synthetic cursor treatment makes the card surface and border respond to motion.

+
+
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RemocnPageComponent { + private readonly seo = inject(SeoService); + + constructor() { + this.seo.setPage('remocn showcase', 'Registry component showcase inside the Angular app shell.'); + } +} diff --git a/src/app/features/next/report-problem.page.ts b/src/app/features/next/report-problem.page.ts new file mode 100644 index 0000000..38b6a4f --- /dev/null +++ b/src/app/features/next/report-problem.page.ts @@ -0,0 +1,129 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { finalize } from 'rxjs'; +import { LocaleService } from '../../core/services/locale.service'; +import { SeoService } from '../../core/services/seo.service'; +import { PROBLEM_REPORT_REPOSITORY } from '../../shared/data-access/content.repositories'; + +@Component({ + selector: 'app-report-problem-page', + imports: [ReactiveFormsModule], + template: ` +
+
+

{{ locale() === 'de' ? 'Support Intake' : 'Support intake' }}

+

{{ locale() === 'de' ? 'Problem melden' : 'Report a problem' }}

+

+ {{ locale() === 'de' ? 'Teile das Problem, wo es passiert ist, und genug Details fuer eine schnelle Reproduktion.' : 'Share the issue, where it happened, and enough detail for someone to reproduce it quickly.' }} +

+
+ +
+
+
+ + + + + + +
+ +

Avoid pasting passwords, secrets, or personal data that is not needed to understand the issue.

+ + + + @if (message()) { +

{{ message() }}

+ } +
+ +
+ @for (tip of tips; track tip.title) { +
+

{{ tip.title }}

+

{{ tip.copy }}

+
+ } +
+
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ReportProblemPageComponent { + private readonly formBuilder = inject(NonNullableFormBuilder); + private readonly problemReportRepository = inject(PROBLEM_REPORT_REPOSITORY); + private readonly localeService = inject(LocaleService); + private readonly seo = inject(SeoService); + protected readonly locale = this.localeService.locale; + protected readonly submitting = signal(false); + protected readonly message = signal(''); + protected readonly hasError = signal(false); + protected readonly areas = ['Bug', 'Performance', 'Account or access', 'Billing', 'Other']; + protected readonly tips = [ + { title: 'Lead with the symptom', copy: 'Describe the visible failure first so triage can classify it quickly.' }, + { title: 'Point to the exact surface', copy: 'Include the page, workflow, or action that triggered the problem.' }, + { title: 'Leave a reachable contact', copy: 'Use an email someone can reply to if more context is needed.' }, + ]; + protected readonly form = this.formBuilder.group({ + name: ['Alex Johnson', Validators.required], + email: ['alex@example.com', [Validators.required, Validators.email]], + area: ['Bug', Validators.required], + pageUrl: ['https://app.example.com/settings', Validators.required], + subject: ['Saving changes closes the modal unexpectedly', Validators.required], + details: ['', [Validators.required, Validators.minLength(20)]], + }); + + constructor() { + this.seo.setPage('Report a problem', 'Support intake form with static and API repository behavior.'); + } + + protected submit(): void { + this.message.set(''); + this.hasError.set(false); + + if (this.form.invalid) { + this.form.markAllAsTouched(); + this.hasError.set(true); + this.message.set('Complete the required fields before sending the report.'); + return; + } + + this.submitting.set(true); + this.form.disable(); + this.problemReportRepository + .submit(this.form.getRawValue()) + .pipe(finalize(() => { + this.submitting.set(false); + this.form.enable(); + })) + .subscribe((result) => { + this.hasError.set(!result.ok); + this.message.set(`${result.message} Reference: ${result.referenceId}`); + }); + } +} diff --git a/src/app/features/next/story.page.ts b/src/app/features/next/story.page.ts new file mode 100644 index 0000000..19042b5 --- /dev/null +++ b/src/app/features/next/story.page.ts @@ -0,0 +1,92 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { AppSettingsService } from '../../core/services/app-settings.service'; +import { LocaleService } from '../../core/services/locale.service'; +import { SeoService } from '../../core/services/seo.service'; +import { STORY_STEPS } from '../../shared/content/next-template.content'; + +@Component({ + selector: 'app-story-page', + template: ` +
+
+

{{ locale() === 'de' ? 'Story-Scroll-Demo' : 'Story Scroll Demo' }}

+

Package-powered story sequence

+

+ {{ locale() === 'de' ? 'Diese Angular-Version bildet die scroll- und tastaturgesteuerte Story als zugaengliche Komponente nach.' : 'This Angular version recreates the scroll and keyboard driven story as an accessible component.' }} +

+
+ +
+ + +
+ +

{{ activeStep().eyebrow }}

+

{{ activeStep().title }}

+

{{ activeStep().lead }}

+
    +
  • Progress {{ (activeIndex() + 1) * 100 - 100 }}-{{ (activeIndex() + 1) * 100 }}
  • +
  • {{ settings.settings().reducedMotion ? 'Reduced motion' : 'Animated' }}
  • +
  • Arrow keys enabled
  • +
+
+ + +
+
+
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StoryPageComponent { + protected readonly settings = inject(AppSettingsService); + private readonly localeService = inject(LocaleService); + private readonly seo = inject(SeoService); + protected readonly locale = this.localeService.locale; + protected readonly steps = STORY_STEPS; + protected readonly activeIndex = signal(0); + protected readonly activeStep = computed(() => this.steps[this.activeIndex()]); + + constructor() { + this.seo.setPage('Story Scroll Demo', 'Keyboard and scroll-friendly story sequence for Angular.'); + } + + protected setActive(index: number): void { + this.activeIndex.set(Math.max(0, Math.min(index, this.steps.length - 1))); + } + + protected reset(): void { + this.activeIndex.set(0); + } + + protected next(): void { + this.setActive(this.activeIndex() + 1); + } + + protected previous(): void { + this.setActive(this.activeIndex() - 1); + } + + protected handleStageKeydown(event: KeyboardEvent): void { + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + event.preventDefault(); + this.next(); + } + + if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + event.preventDefault(); + this.previous(); + } + } +} diff --git a/src/app/features/next/table.page.ts b/src/app/features/next/table.page.ts new file mode 100644 index 0000000..9810c1c --- /dev/null +++ b/src/app/features/next/table.page.ts @@ -0,0 +1,93 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { SeoService } from '../../core/services/seo.service'; +import { EMPLOYEE_REPOSITORY } from '../../shared/data-access/content.repositories'; +import { EmployeeRecord } from '../../shared/models/content.models'; + +@Component({ + selector: 'app-table-page', + template: ` +
+
+

Data view

+

Employee table

+

A repository-backed table with loading, empty, and error states.

+
+ +
+ + + @if (loading()) { +

Loading endpoint data...

+ } @else if (hasError()) { +

Unable to load endpoint data.

+ } @else if (filteredEmployees().length === 0) { +

No employees match the current filter.

+ } @else { +
+ + + + + + + + + + + + @for (employee of filteredEmployees(); track employee.id) { + + + + + + + + } + +
NameDepartmentRoleStatusLocation
{{ employee.name }}{{ employee.department }}{{ employee.role }}{{ employee.status }}{{ employee.location }}
+
+ } +
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TablePageComponent { + private readonly employeeRepository = inject(EMPLOYEE_REPOSITORY); + private readonly seo = inject(SeoService); + protected readonly employees = signal([]); + protected readonly loading = signal(true); + protected readonly hasError = signal(false); + protected readonly query = signal(''); + protected readonly filteredEmployees = computed(() => { + const query = this.query().trim().toLowerCase(); + return this.employees().filter((employee) => + `${employee.name} ${employee.department} ${employee.role} ${employee.location} ${employee.status}` + .toLowerCase() + .includes(query), + ); + }); + + constructor() { + this.seo.setPage('Employee table', 'Repository-backed employee table with static and API modes.'); + this.employeeRepository.list().subscribe({ + next: (employees) => { + this.employees.set(employees); + this.loading.set(false); + }, + error: () => { + this.hasError.set(true); + this.loading.set(false); + }, + }); + } + + protected updateQuery(event: Event): void { + this.query.set((event.target as HTMLInputElement).value); + } +} diff --git a/src/app/features/next/uploads.page.ts b/src/app/features/next/uploads.page.ts new file mode 100644 index 0000000..bb43cc6 --- /dev/null +++ b/src/app/features/next/uploads.page.ts @@ -0,0 +1,127 @@ +import { DecimalPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { UploadClassifierService } from '../../core/services/upload-classifier.service'; +import { LocaleService } from '../../core/services/locale.service'; +import { SeoService } from '../../core/services/seo.service'; +import { UploadQueueItem } from '../../shared/models/content.models'; + +@Component({ + selector: 'app-uploads-page', + imports: [DecimalPipe], + template: ` +
+
+

{{ locale() === 'de' ? 'Plattformuebergreifender Upload-Starter' : 'Cross-platform upload starter' }}

+

Uploads

+

+ {{ locale() === 'de' ? 'Eine Browser-Queue-Demo plus Entwurfsregeln fuer echte Storage-Flows.' : 'A browser queue demo plus the design rules you usually need before implementing real storage flows.' }} +

+
+ +
+
+
+

Browser intake

+

{{ locale() === 'de' ? 'Uploads vor der Netzwerkschicht normalisieren' : 'Normalize uploads before the network layer' }}

+

{{ locale() === 'de' ? 'Lege Dateien hier ab oder nutze den Browser-Picker.' : 'Drop files here or use the browser picker.' }}

+ +
+
+ +
+
+
+

{{ locale() === 'de' ? 'Aktuelle Upload-Elemente' : 'Current upload items' }}

+

{{ queue().length }} queued

+
+ +
+ +
+ @if (queue().length === 0) { +

{{ locale() === 'de' ? 'Noch keine Dateien. Fuege ein paar hinzu, um Klassifizierung und Strategie zu sehen.' : 'No files yet. Add a few files to see classification and strategy.' }}

+ } + + @for (item of queue(); track item.id) { +
+
+ {{ item.name }} +

{{ item.kind }} · {{ item.strategy }} · {{ item.size / 1024 | number: '1.0-0' }} KB

+
+ {{ item.type }} +
+ } +
+
+
+ +
+ @for (group of acceptedGroups; track group.title) { +
+

{{ group.eyebrow }}

+

{{ group.title }}

+
    + @for (item of group.items; track item) { +
  • {{ item }}
  • + } +
+
+ } +
+
+ `, + styleUrl: './next-pages.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UploadsPageComponent { + private readonly classifier = inject(UploadClassifierService); + private readonly localeService = inject(LocaleService); + private readonly seo = inject(SeoService); + protected readonly locale = this.localeService.locale; + protected readonly queue = signal([]); + protected readonly acceptedGroups = [ + { eyebrow: 'Images', title: 'Preview and optimize', items: ['png', 'jpg', 'webp', 'svg'] }, + { eyebrow: 'Documents', title: 'Scan and store', items: ['pdf', 'docx', 'txt', 'md'] }, + { eyebrow: 'Media', title: 'Transcode or chunk', items: ['mp4', 'mov', 'mp3', 'wav'] }, + ]; + + constructor() { + this.seo.setPage('Uploads', 'Browser upload queue with classification and strategy suggestions.'); + } + + protected handleDrag(event: DragEvent): void { + event.preventDefault(); + } + + protected handleDrop(event: DragEvent): void { + event.preventDefault(); + this.addFiles(event.dataTransfer?.files); + } + + protected handleFileInput(event: Event): void { + this.addFiles((event.target as HTMLInputElement).files); + (event.target as HTMLInputElement).value = ''; + } + + protected clear(): void { + this.queue.set([]); + } + + private addFiles(files: FileList | null | undefined): void { + if (!files) { + return; + } + + const items = Array.from(files).map((file) => this.classifier.classify(file)); + this.queue.update((queue) => [...queue, ...items]); + } +} diff --git a/src/app/shared/content/next-template.content.ts b/src/app/shared/content/next-template.content.ts new file mode 100644 index 0000000..aa5f2aa --- /dev/null +++ b/src/app/shared/content/next-template.content.ts @@ -0,0 +1,230 @@ +import { BlogPostSummary, ChangelogEntry, EmployeeRecord, Locale } from '../models/content.models'; + +export const LOCALES: readonly Locale[] = ['en', 'de']; + +export interface LocalizedPageCopy { + readonly eyebrow: string; + readonly title: string; + readonly lead: string; +} + +export interface NavigationEntry { + readonly label: string; + readonly path: string; + readonly hotkey: string; +} + +export interface NavigationGroup { + readonly label: string; + readonly entries: readonly NavigationEntry[]; +} + +export const NAVIGATION: Record = { + en: [ + { + label: 'Discover', + entries: [ + { label: 'Home', path: '', hotkey: 'H' }, + { label: 'About', path: 'about', hotkey: 'A' }, + { label: 'remocn', path: 'remocn', hotkey: 'V' }, + { label: 'Story Demo', path: 'examples/story', hotkey: 'S' }, + { label: 'Communication', path: 'examples/communication', hotkey: 'C' }, + { label: 'Blog', path: 'blog', hotkey: 'G' }, + { label: 'Changelog', path: 'changelog', hotkey: 'K' }, + { label: 'Report a problem', path: 'report-problem', hotkey: 'B' }, + ], + }, + { + label: 'Workspace', + entries: [ + { label: 'Form Demo', path: 'examples/forms', hotkey: 'F' }, + { label: 'Table', path: 'table', hotkey: 'T' }, + { label: 'Uploads', path: 'examples/uploads', hotkey: 'U' }, + ], + }, + { + label: 'Account', + entries: [ + { label: 'Log in', path: 'login', hotkey: 'L' }, + { label: 'Register', path: 'register', hotkey: 'R' }, + ], + }, + ], + de: [ + { + label: 'Entdecken', + entries: [ + { label: 'Start', path: '', hotkey: 'H' }, + { label: 'Ueber uns', path: 'about', hotkey: 'A' }, + { label: 'remocn', path: 'remocn', hotkey: 'V' }, + { label: 'Story-Demo', path: 'examples/story', hotkey: 'S' }, + { label: 'Kommunikation', path: 'examples/communication', hotkey: 'C' }, + { label: 'Blog', path: 'blog', hotkey: 'G' }, + { label: 'Changelog', path: 'changelog', hotkey: 'K' }, + { label: 'Problem melden', path: 'report-problem', hotkey: 'B' }, + ], + }, + { + label: 'Arbeitsbereich', + entries: [ + { label: 'Formular-Demo', path: 'examples/forms', hotkey: 'F' }, + { label: 'Tabelle', path: 'table', hotkey: 'T' }, + { label: 'Uploads', path: 'examples/uploads', hotkey: 'U' }, + ], + }, + { + label: 'Konto', + entries: [ + { label: 'Anmelden', path: 'login', hotkey: 'L' }, + { label: 'Registrieren', path: 'register', hotkey: 'R' }, + ], + }, + ], +}; + +export const HOME_COPY: Record = { + en: { + eyebrow: 'Start', + title: 'One template, multiple production-ready starting points.', + lead: 'The merged starter combines localized navigation, auth, forms, data views, storytelling, communication notes, and upload scaffolding in one Angular base.', + }, + de: { + eyebrow: 'Start', + title: 'Eine Vorlage mit mehreren produktionsnahen Ausgangspunkten.', + lead: 'Der zusammengefuehrte Starter vereint lokalisierte Navigation, Auth, Formulare, Datenansichten, Storytelling, Kommunikationsleitlinien und Upload-Grundlagen in einer Angular-Basis.', + }, +}; + +export const HOME_FEATURES: Record = { + en: [ + { + eyebrow: 'Application foundation', + title: 'Authentication, locale routing, profile management, admin controls, and typed service boundaries.', + lead: 'Use the static app immediately and switch selected repositories to API-backed behavior later.', + }, + { + eyebrow: 'Interaction demos', + title: 'Form state, scroll-driven storytelling, data tables, and richer UI patterns for future features.', + lead: 'Each demo has real state and accessible controls instead of placeholder copy.', + }, + { + eyebrow: 'Delivery scaffolding', + title: 'Upload handling rules, communication guidance, and lanes for realtime work.', + lead: 'Server endpoints stay optional so GitHub Pages remains a first-class target.', + }, + ], + de: [ + { + eyebrow: 'Anwendungsfundament', + title: 'Authentifizierung, Locale-Routing, Profilverwaltung, Admin-Steuerung und typisierte Service-Grenzen.', + lead: 'Nutze die statische App sofort und schalte einzelne Repositories spaeter auf API-Betrieb um.', + }, + { + eyebrow: 'Interaktionsdemos', + title: 'Formularzustand, scrollgesteuertes Storytelling, Datentabellen und staerkere UI-Muster.', + lead: 'Jede Demo enthaelt echten Zustand und zugaengliche Controls statt Platzhaltertext.', + }, + { + eyebrow: 'Auslieferungsgrundlagen', + title: 'Upload-Regeln, Kommunikationsleitlinien und Bereiche fuer Echtzeit-Arbeit.', + lead: 'Server-Endpunkte bleiben optional, damit GitHub Pages ein vollwertiges Ziel bleibt.', + }, + ], +}; + +export const ABOUT_COPY: Record = { + en: { + eyebrow: 'About this project', + title: 'Locale routing and reusable building blocks inside one app shell.', + lead: 'The language lives in the URL, page copy is typed, and each feature route can move from static data to backend services without changing the shell.', + }, + de: { + eyebrow: 'Ueber dieses Projekt', + title: 'Locale-Routing und wiederverwendbare Bausteine in einer App-Shell.', + lead: 'Die Sprache lebt in der URL, Seitentexte sind typisiert, und jede Feature-Route kann von statischen Daten zu Backend-Services wechseln.', + }, +}; + +export const BLOG_POSTS: Record = { + en: [ + { + date: '2026-04-13', + title: 'Building Hybrid Sites', + summary: 'Repo-managed content and DB-backed operational data can coexist cleanly.', + }, + { + date: '2026-04-12', + title: 'Template Launch', + summary: 'Canonical marketing content now lives in typed source files.', + }, + ], + de: [ + { + date: '2026-04-13', + title: 'Hybride Sites bauen', + summary: 'Repo-verwaltete Inhalte und operative Daten aus APIs koennen sauber nebeneinander existieren.', + }, + { + date: '2026-04-12', + title: 'Template Launch', + summary: 'Kanonische Marketing-Inhalte liegen jetzt typisiert im Quellcode.', + }, + ], +}; + +export const CHANGELOG_ENTRIES: Record = { + en: [ + { + date: '2026-04-12', + title: 'Foundation Hardening', + summary: 'Environment, routing, observability, and job foundations were standardized.', + }, + ], + de: [ + { + date: '2026-04-12', + title: 'Fundament gehaertet', + summary: 'Environment, Routing, Beobachtbarkeit und Job-Grundlagen wurden standardisiert.', + }, + ], +}; + +export const EMPLOYEES: readonly EmployeeRecord[] = [ + { id: 1, name: 'Alex Johnson', department: 'Engineering', role: 'Frontend Lead', status: 'Active', location: 'Berlin' }, + { id: 2, name: 'Mina Keller', department: 'Product', role: 'Product Manager', status: 'Onboarding', location: 'Hamburg' }, + { id: 3, name: 'Sam Rivera', department: 'Design', role: 'UX Designer', status: 'Active', location: 'Remote' }, + { id: 4, name: 'Lena Roth', department: 'People Ops', role: 'Operations Partner', status: 'Paused', location: 'Munich' }, +]; + +export const FORM_STATE_NOTES: Record = { + en: [ + { eyebrow: 'FormGroup', title: 'Creates the form API, default values, validators, and state object that everything else reads from.', lead: 'The Angular equivalent of the form controller lives in a typed reactive form.' }, + { eyebrow: 'formControlName', title: 'Connects native controls to the form without extra wrappers.', lead: 'Validation and value updates stay inside the same form tree.' }, + { eyebrow: 'Custom controls', title: 'Use ControlValueAccessor when a direct formControlName binding is not enough.', lead: 'The same validation and touched state still flow through the parent form.' }, + { eyebrow: 'reset', title: 'Restores defaults, clears errors, and can optionally rebase the clean snapshot.', lead: 'The demo uses reset after a successful submission.' }, + ], + de: [ + { eyebrow: 'FormGroup', title: 'Erzeugt Formular-API, Default-Werte, Validatoren und den State, aus dem alles andere liest.', lead: 'Das Angular-Gegenstueck zum Form Controller lebt in einem typisierten Reactive Form.' }, + { eyebrow: 'formControlName', title: 'Verbindet native Controls ohne zusaetzliche Wrapper mit dem Formular.', lead: 'Validierung und Werte bleiben im selben Formularbaum.' }, + { eyebrow: 'Custom Controls', title: 'Nutze ControlValueAccessor, wenn formControlName allein nicht reicht.', lead: 'Validierung und touched state laufen weiter durch das Parent-Formular.' }, + { eyebrow: 'reset', title: 'Stellt Defaults wieder her, loescht Fehler und kann den sauberen Snapshot neu setzen.', lead: 'Die Demo nutzt reset nach erfolgreichem Senden.' }, + ], +}; + +export const STORY_STEPS: readonly LocalizedPageCopy[] = [ + { + eyebrow: 'Scene 1', + title: 'Foundation', + lead: 'Start with a broad system view before narrowing the camera onto the first interaction.', + }, + { + eyebrow: 'Scene 2', + title: 'Handoff', + lead: 'Transition between narrative beats with enough motion to show continuity without losing context.', + }, + { + eyebrow: 'Scene 3', + title: 'Resolution', + lead: 'Land the final scene with a calmer pace so the content can carry the end of the sequence.', + }, +]; diff --git a/src/app/shared/data-access/content.repositories.ts b/src/app/shared/data-access/content.repositories.ts index a63d748..feafab7 100644 --- a/src/app/shared/data-access/content.repositories.ts +++ b/src/app/shared/data-access/content.repositories.ts @@ -3,12 +3,21 @@ import { inject, Injectable, InjectionToken } from '@angular/core'; import { delay, Observable, of } from 'rxjs'; import { APP_ENVIRONMENT } from '../../core/config/environment.token'; import { + AuthCredentials, + AuthResult, ContactRequest, ContactSubmissionResult, + EmployeeRecord, + NewsletterSubscription, + PasswordResetRequest, + ProblemReportRequest, + ProblemReportResult, + RegisterRequest, ShowcaseRecord, TemplateRecord, } from '../models/content.models'; import { SHOWCASES, TEMPLATES } from '../content/site.content'; +import { EMPLOYEES } from '../content/next-template.content'; export interface TemplatesRepository { list(): Observable; @@ -26,9 +35,31 @@ export interface ContactRepository { submit(request: ContactRequest): Observable; } +export interface AuthRepository { + login(request: AuthCredentials): Observable; + register(request: RegisterRequest): Observable; + requestPasswordReset(request: PasswordResetRequest): Observable; +} + +export interface EmployeeRepository { + list(): Observable; +} + +export interface NewsletterRepository { + subscribe(request: NewsletterSubscription): Observable; +} + +export interface ProblemReportRepository { + submit(request: ProblemReportRequest): Observable; +} + export const TEMPLATES_REPOSITORY = new InjectionToken('TEMPLATES_REPOSITORY'); export const SHOWCASE_REPOSITORY = new InjectionToken('SHOWCASE_REPOSITORY'); export const CONTACT_REPOSITORY = new InjectionToken('CONTACT_REPOSITORY'); +export const AUTH_REPOSITORY = new InjectionToken('AUTH_REPOSITORY'); +export const EMPLOYEE_REPOSITORY = new InjectionToken('EMPLOYEE_REPOSITORY'); +export const NEWSLETTER_REPOSITORY = new InjectionToken('NEWSLETTER_REPOSITORY'); +export const PROBLEM_REPORT_REPOSITORY = new InjectionToken('PROBLEM_REPORT_REPOSITORY'); @Injectable({ providedIn: 'root', @@ -130,3 +161,122 @@ export class ApiContactRepository implements ContactRepository { ); } } + +@Injectable({ + providedIn: 'root', +}) +export class StaticAuthRepository implements AuthRepository { + login(request: AuthCredentials): Observable { + return of({ + ok: request.email.includes('@') && request.password.length > 0, + message: 'Signed in with the static auth adapter. Swap to the server build for real sessions.', + }).pipe(delay(250)); + } + + register(request: RegisterRequest): Observable { + const passwordsMatch = request.password === request.confirmPassword; + return of({ + ok: passwordsMatch, + message: passwordsMatch + ? 'Account created in static mode and ready for the connected adapter.' + : 'Passwords must match before the account can be created.', + }).pipe(delay(250)); + } + + requestPasswordReset(_: PasswordResetRequest): Observable { + return of({ + ok: true, + message: 'Reset link queued in static mode.', + }).pipe(delay(250)); + } +} + +@Injectable({ + providedIn: 'root', +}) +export class ApiAuthRepository implements AuthRepository { + private readonly http = inject(HttpClient); + private readonly environment = inject(APP_ENVIRONMENT); + + login(request: AuthCredentials): Observable { + return this.http.post(`${this.environment.apiBaseUrl}/auth/login`, request); + } + + register(request: RegisterRequest): Observable { + return this.http.post(`${this.environment.apiBaseUrl}/auth/register`, request); + } + + requestPasswordReset(request: PasswordResetRequest): Observable { + return this.http.post(`${this.environment.apiBaseUrl}/auth/password-reset`, request); + } +} + +@Injectable({ + providedIn: 'root', +}) +export class StaticEmployeeRepository implements EmployeeRepository { + list(): Observable { + return of(EMPLOYEES).pipe(delay(150)); + } +} + +@Injectable({ + providedIn: 'root', +}) +export class ApiEmployeeRepository implements EmployeeRepository { + private readonly http = inject(HttpClient); + private readonly environment = inject(APP_ENVIRONMENT); + + list(): Observable { + return this.http.get(`${this.environment.apiBaseUrl}/employees`); + } +} + +@Injectable({ + providedIn: 'root', +}) +export class StaticNewsletterRepository implements NewsletterRepository { + subscribe(request: NewsletterSubscription): Observable { + return of({ + ok: request.email.includes('@'), + message: 'Newsletter subscription captured in static mode.', + }).pipe(delay(250)); + } +} + +@Injectable({ + providedIn: 'root', +}) +export class ApiNewsletterRepository implements NewsletterRepository { + private readonly http = inject(HttpClient); + private readonly environment = inject(APP_ENVIRONMENT); + + subscribe(request: NewsletterSubscription): Observable { + return this.http.post(`${this.environment.apiBaseUrl}/newsletter`, request); + } +} + +@Injectable({ + providedIn: 'root', +}) +export class StaticProblemReportRepository implements ProblemReportRepository { + submit(_: ProblemReportRequest): Observable { + return of({ + ok: true, + referenceId: `STATIC-${Date.now().toString(36).toUpperCase()}`, + message: 'Problem report captured in static mode.', + }).pipe(delay(250)); + } +} + +@Injectable({ + providedIn: 'root', +}) +export class ApiProblemReportRepository implements ProblemReportRepository { + private readonly http = inject(HttpClient); + private readonly environment = inject(APP_ENVIRONMENT); + + submit(request: ProblemReportRequest): Observable { + return this.http.post(`${this.environment.apiBaseUrl}/problem-reports`, request); + } +} diff --git a/src/app/shared/data-access/content.repositories.unit.spec.ts b/src/app/shared/data-access/content.repositories.unit.spec.ts index 6266853..558d81f 100644 --- a/src/app/shared/data-access/content.repositories.unit.spec.ts +++ b/src/app/shared/data-access/content.repositories.unit.spec.ts @@ -6,8 +6,12 @@ import { environment } from '../../../environments/environment'; import { APP_ENVIRONMENT } from '../../core/config/environment.token'; import { TEMPLATES } from '../content/site.content'; import { + ApiNewsletterRepository, ApiTemplatesRepository, + StaticAuthRepository, StaticContactRepository, + StaticEmployeeRepository, + StaticProblemReportRepository, StaticTemplatesRepository, } from './content.repositories'; @@ -48,6 +52,34 @@ describe('Content repositories unit', () => { expect(result.ok).toBe(true); }); + + it('supports the new static auth, employee, and report repositories', async () => { + const authRepository = TestBed.inject(StaticAuthRepository); + const employeeRepository = TestBed.inject(StaticEmployeeRepository); + const reportRepository = TestBed.inject(StaticProblemReportRepository); + + const auth = await firstValueFrom( + authRepository.login({ + email: 'alex@example.com', + password: 'password', + }), + ); + const employees = await firstValueFrom(employeeRepository.list()); + const report = await firstValueFrom( + reportRepository.submit({ + name: 'Alex', + email: 'alex@example.com', + area: 'Bug', + pageUrl: 'https://app.example.test', + subject: 'Unexpected state', + details: 'The application entered an unexpected state after saving.', + }), + ); + + expect(auth.ok).toBe(true); + expect(employees.length).toBeGreaterThan(0); + expect(report.referenceId).toMatch(/^STATIC-/); + }); }); describe('API repositories', () => { @@ -85,5 +117,18 @@ describe('Content repositories unit', () => { await expect(request).resolves.toEqual(TEMPLATES[0]); }); + + it('posts newsletter subscriptions to the configured API endpoint', async () => { + const repository = TestBed.inject(ApiNewsletterRepository); + const request = firstValueFrom(repository.subscribe({ email: 'alex@example.com' })); + const http = TestBed.inject(HttpTestingController); + + const pending = http.expectOne('https://api.example.test/newsletter'); + expect(pending.request.method).toBe('POST'); + + pending.flush({ ok: true, message: 'Subscribed.' }); + + await expect(request).resolves.toEqual({ ok: true, message: 'Subscribed.' }); + }); }); }); diff --git a/src/app/shared/models/content.models.ts b/src/app/shared/models/content.models.ts index 8584d2b..0287529 100644 --- a/src/app/shared/models/content.models.ts +++ b/src/app/shared/models/content.models.ts @@ -1,4 +1,7 @@ export type DeploymentMode = 'Static' | 'Connected' | 'Server'; +export type Locale = 'en' | 'de'; + +export type LocalizedContent = Record; export interface SiteAsset { readonly src: string; @@ -70,3 +73,107 @@ export interface ContactSubmissionResult { readonly ok: boolean; readonly message: string; } + +export interface AuthCredentials { + readonly email: string; + readonly password: string; +} + +export interface RegisterRequest extends AuthCredentials { + readonly name: string; + readonly confirmPassword: string; +} + +export interface PasswordResetRequest { + readonly email: string; +} + +export interface AuthResult { + readonly ok: boolean; + readonly message: string; +} + +export interface EmployeeProfileForm { + readonly firstName: string; + readonly lastName: string; + readonly email: string; + readonly phone: string; + readonly age: number; + readonly jobTitle: string; + readonly startDate: string; + readonly department: string; + readonly newsletter: boolean; + readonly bio: string; +} + +export interface EmployeeRecord { + readonly id: number; + readonly name: string; + readonly department: string; + readonly role: string; + readonly status: 'Active' | 'Onboarding' | 'Paused'; + readonly location: string; +} + +export interface NewsletterSubscription { + readonly email: string; +} + +export interface ProblemReportRequest { + readonly name: string; + readonly email: string; + readonly area: string; + readonly pageUrl: string; + readonly subject: string; + readonly details: string; +} + +export interface ProblemReportResult { + readonly ok: boolean; + readonly referenceId: string; + readonly message: string; +} + +export type UploadKind = 'Image' | 'Document' | 'Media' | 'Data file' | 'Other'; +export type UploadStrategy = 'Preview and optimize' | 'Scan and store' | 'Transcode or chunk' | 'Validate schema' | 'Manual review'; + +export interface UploadQueueItem { + readonly id: string; + readonly name: string; + readonly size: number; + readonly type: string; + readonly kind: UploadKind; + readonly strategy: UploadStrategy; +} + +export interface BlogPostSummary { + readonly date: string; + readonly title: string; + readonly summary: string; +} + +export interface ChangelogEntry { + readonly date: string; + readonly title: string; + readonly summary: string; +} + +export interface AppSettings { + readonly theme: 'light' | 'dark'; + readonly background: 'paper' | 'plain'; + readonly dateFormat: 'localized' | 'iso'; + readonly weekStartsOn: 0 | 1; + readonly showOutsideDays: boolean; + readonly compactSpacing: boolean; + readonly reducedMotion: boolean; + readonly showHotkeyHints: boolean; + readonly notifications: { + readonly enabled: boolean; + readonly type: 'instant' | 'digest'; + }; +} + +export interface ConsentState { + readonly decided: boolean; + readonly analytics: boolean; +} diff --git a/src/environments/environment.connected.ts b/src/environments/environment.connected.ts index a426700..b731244 100644 --- a/src/environments/environment.connected.ts +++ b/src/environments/environment.connected.ts @@ -1,9 +1,9 @@ import { AppEnvironment } from '../app/core/config/app-environment'; export const environment: AppEnvironment = { - siteName: 'Foundry Stack', + siteName: 'Next Template', siteDescription: - 'A connected Angular deployment that reads content and form actions from backend services.', + 'A connected Angular deployment that reads application data and form actions from backend services.', siteUrl: 'https://example.com', contentMode: 'api', apiBaseUrl: '/api', diff --git a/src/environments/environment.server.ts b/src/environments/environment.server.ts index 3a68ae9..556e879 100644 --- a/src/environments/environment.server.ts +++ b/src/environments/environment.server.ts @@ -1,7 +1,7 @@ import { AppEnvironment } from '../app/core/config/app-environment'; export const environment: AppEnvironment = { - siteName: 'Foundry Stack', + siteName: 'Next Template', siteDescription: 'A server-backed Angular deployment with optional APIs and SSR-compatible delivery.', siteUrl: 'https://example.com', diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 74804e1..e368462 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,9 +1,9 @@ import { AppEnvironment } from '../app/core/config/app-environment'; export const environment: AppEnvironment = { - siteName: 'Foundry Stack', + siteName: 'Next Template', siteDescription: - 'A static-first Angular template and showcase platform that can also plug into backend services.', + 'A localized Angular template with auth, forms, content, uploads, and optional backend services.', siteUrl: 'https://example.com', contentMode: 'static', apiBaseUrl: null, diff --git a/src/index.html b/src/index.html index cbbe9cd..fd960e7 100644 --- a/src/index.html +++ b/src/index.html @@ -2,14 +2,44 @@ - Foundry Stack + Next Template + diff --git a/src/server.ts b/src/server.ts index d0178dd..c75dec3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,7 +7,15 @@ import { import express from 'express'; import { join } from 'node:path'; import { SHOWCASES, TEMPLATES } from './app/shared/content/site.content'; -import { ContactRequest } from './app/shared/models/content.models'; +import { EMPLOYEES } from './app/shared/content/next-template.content'; +import { + AuthCredentials, + ContactRequest, + NewsletterSubscription, + PasswordResetRequest, + ProblemReportRequest, + RegisterRequest, +} from './app/shared/models/content.models'; const browserDistFolder = join(import.meta.dirname, '../browser'); @@ -70,6 +78,80 @@ app.post('/api/contact', (req, res) => { }); }); +app.post('/api/auth/login', (req, res) => { + const body = req.body as Partial | undefined; + if (!body?.email || !body.password) { + res.status(400).json({ ok: false, message: 'Email and password are required.' }); + return; + } + + res.json({ + ok: true, + message: 'Signed in through the server auth stub. Replace this with a real session provider.', + }); +}); + +app.post('/api/auth/register', (req, res) => { + const body = req.body as Partial | undefined; + if (!body?.name || !body.email || !body.password || body.password !== body.confirmPassword) { + res.status(400).json({ ok: false, message: 'Name, email, and matching passwords are required.' }); + return; + } + + res.json({ + ok: true, + message: `Created an account stub for ${body.name}. Connect this to your auth provider in production.`, + }); +}); + +app.post('/api/auth/password-reset', (req, res) => { + const body = req.body as Partial | undefined; + if (!body?.email) { + res.status(400).json({ ok: false, message: 'Email is required.' }); + return; + } + + res.json({ + ok: true, + message: 'Password reset request accepted by the server stub.', + }); +}); + +app.post('/api/newsletter', (req, res) => { + const body = req.body as Partial | undefined; + if (!body?.email) { + res.status(400).json({ ok: false, message: 'Email is required.' }); + return; + } + + res.json({ + ok: true, + message: `Subscribed ${body.email} through the server newsletter stub.`, + }); +}); + +app.post('/api/problem-reports', (req, res) => { + const body = req.body as Partial | undefined; + if (!body?.name || !body.email || !body.subject || !body.details) { + res.status(400).json({ + ok: false, + referenceId: '', + message: 'Name, email, subject, and details are required.', + }); + return; + } + + res.json({ + ok: true, + referenceId: `SRV-${Date.now().toString(36).toUpperCase()}`, + message: 'Problem report accepted by the server stub.', + }); +}); + +app.get('/api/employees', (_req, res) => { + res.json(EMPLOYEES); +}); + /** * Serve static files from /browser */ diff --git a/src/styles.css b/src/styles.css index 71be269..a2a4c6d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2,41 +2,55 @@ :root { color-scheme: light; - --sand-50: #f7f0e6; - --sand-100: #efe4d5; - --surface: rgba(255, 250, 244, 0.84); - --surface-strong: rgba(255, 244, 228, 0.94); - --ink-950: #101317; - --ink-800: #1d242c; - --ink-700: #29323c; - --ink-500: #5a6570; - --ink-400: #758392; - --line: rgba(16, 19, 23, 0.12); - --line-strong: rgba(16, 19, 23, 0.2); - --amber-500: #ff9d39; - --amber-700: #a45b12; - --teal-500: #37c3b4; - --teal-700: #136c63; - --error: #a2322a; - --shadow: 0 24px 70px rgba(30, 20, 10, 0.12); + --surface: #fafafa; + --surface-raised: #ffffff; + --surface-muted: #f4f4f5; + --ink-950: #09090b; + --ink-900: #18181b; + --ink-700: #3f3f46; + --ink-600: #52525b; + --ink-500: #71717a; + --line: rgba(24, 24, 27, 0.12); + --line-strong: rgba(24, 24, 27, 0.22); + --accent: #2563eb; + --accent-strong: #1d4ed8; + --success: #047857; + --error: #b91c1c; + --warning: #b45309; + --shadow: 0 18px 50px rgba(24, 24, 27, 0.12); font-family: - "Sora", + Inter, "Segoe UI", + system-ui, sans-serif; } +:root.dark { + color-scheme: dark; + --surface: #09090b; + --surface-raised: #18181b; + --surface-muted: #27272a; + --ink-950: #fafafa; + --ink-900: #f4f4f5; + --ink-700: #d4d4d8; + --ink-600: #a1a1aa; + --ink-500: #71717a; + --line: rgba(250, 250, 250, 0.14); + --line-strong: rgba(250, 250, 250, 0.24); + --shadow: 0 18px 50px rgba(0, 0, 0, 0.35); +} + html { scroll-behavior: smooth; } body { margin: 0; - color: var(--ink-800); - background: - radial-gradient(circle at top left, rgba(255, 157, 57, 0.22), transparent 28%), - radial-gradient(circle at top right, rgba(55, 195, 180, 0.17), transparent 30%), - linear-gradient(180deg, #fffaf4 0%, #f8f1e7 100%); min-height: 100dvh; + color: var(--ink-900); + background: + linear-gradient(180deg, color-mix(in srgb, var(--surface-muted) 70%, transparent), transparent 22rem), + var(--surface); } *, @@ -76,26 +90,71 @@ input, select, textarea { width: 100%; - padding: 0.85rem 1rem; - border-radius: 0.95rem; border: 1px solid var(--line-strong); - background: rgba(255, 255, 255, 0.8); + border-radius: 8px; + padding: 0.8rem 0.9rem; + background: var(--surface-raised); color: var(--ink-950); } +input[type='checkbox'], +input[type='radio'] { + width: auto; + inline-size: 1rem; + block-size: 1rem; + padding: 0; +} + +.checkbox-field span { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.visually-hidden { + position: absolute; + inline-size: 1px; + block-size: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + clip-path: inset(50%); +} + button { cursor: pointer; border: 0; } +button:disabled { + cursor: not-allowed; + opacity: 0.65; +} + :focus-visible { - outline: 3px solid color-mix(in srgb, var(--teal-500) 65%, white); + outline: 3px solid color-mix(in srgb, var(--accent) 70%, white); outline-offset: 3px; } .page-shell { + width: min(1120px, calc(100% - 2rem)); + margin: 0 auto; display: grid; gap: 2rem; + padding: 3rem 0 0; +} + +.page-intro { + display: grid; + gap: 1rem; + max-width: 820px; +} + +.page-intro h1 { + margin: 0; + font-size: clamp(2.25rem, 6vw, 5rem); + line-height: 0.98; + letter-spacing: 0; } .section { @@ -110,43 +169,44 @@ button { .section-heading h2 { margin: 0; - font-size: clamp(1.8rem, 3vw, 3rem); - line-height: 1.04; - letter-spacing: -0.04em; + font-size: clamp(1.6rem, 3vw, 2.7rem); + line-height: 1.05; + letter-spacing: 0; } .eyebrow { margin: 0; text-transform: uppercase; - letter-spacing: 0.14em; - font-size: 0.8rem; - font-weight: 700; - color: var(--amber-700); + letter-spacing: 0.12em; + font-size: 0.78rem; + font-weight: 800; + color: var(--accent-strong); } .lead-copy { + margin: 0; color: var(--ink-700); - font-size: 1.1rem; - max-width: 60ch; + font-size: 1.08rem; + line-height: 1.7; + max-width: 70ch; } .muted-copy { - color: var(--ink-500); + color: var(--ink-600); } .content-card { - padding: 1.4rem; border: 1px solid var(--line); - border-radius: 1.4rem; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 247, 240, 0.8)); + border-radius: 8px; + padding: 1.25rem; + background: var(--surface-raised); box-shadow: var(--shadow); } .card-grid { display: grid; - gap: 1rem; grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; } .card-grid--three { @@ -154,43 +214,39 @@ button { } .button { + min-height: 2.75rem; display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; - min-height: 3rem; - padding: 0.85rem 1.15rem; border-radius: 999px; + padding: 0.75rem 1rem; text-decoration: none; - font-weight: 700; + font-weight: 800; } .button--primary { background: var(--ink-950); - color: var(--sand-50); + color: var(--surface); } .button--secondary { - background: rgba(255, 255, 255, 0.78); - color: var(--ink-950); border: 1px solid var(--line-strong); + background: var(--surface-raised); + color: var(--ink-950); } -.pill { - display: inline-flex; - align-items: center; - min-height: 2rem; - padding: 0.3rem 0.7rem; - border-radius: 999px; - background: rgba(255, 157, 57, 0.14); - color: var(--amber-700); - font-size: 0.82rem; +.field { + display: grid; + gap: 0.4rem; + color: var(--ink-700); font-weight: 700; } -.pill--alt { - background: rgba(55, 195, 180, 0.16); - color: var(--teal-700); +.field small, +.error-copy { + color: var(--error); + font-weight: 700; } .stack-list, @@ -215,33 +271,75 @@ button { position: absolute; left: 0; top: 0.65rem; - inline-size: 0.4rem; - block-size: 0.4rem; - border-radius: 50%; - background: var(--amber-500); + inline-size: 0.38rem; + block-size: 0.38rem; + border-radius: 999px; + background: var(--accent); } .inline-list { display: flex; - gap: 0.55rem; flex-wrap: wrap; + gap: 0.55rem; } -.inline-list li { +.inline-list li, +.pill { border-radius: 999px; - padding: 0.45rem 0.75rem; - background: rgba(16, 19, 23, 0.05); + padding: 0.4rem 0.7rem; + background: var(--surface-muted); color: var(--ink-700); - font-size: 0.9rem; + font-size: 0.88rem; + font-weight: 800; +} + +.status-copy { + margin: 0; + color: var(--success); + font-weight: 800; +} + +.status-copy--error { + color: var(--error); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.form-grid .field--full, +.field--full { + grid-column: 1 / -1; } pre { overflow-x: auto; margin: 0; + border-radius: 8px; padding: 1rem; - border-radius: 1rem; - background: #11161c; - color: #f6efe5; + background: #111827; + color: #f9fafb; +} + +table { + width: 100%; + border-collapse: collapse; + overflow: hidden; + border-radius: 8px; +} + +th, +td { + border-bottom: 1px solid var(--line); + padding: 0.85rem; + text-align: left; +} + +th { + color: var(--ink-700); + font-size: 0.85rem; } @media (prefers-reduced-motion: reduce) { @@ -252,7 +350,15 @@ pre { @media (max-width: 900px) { .card-grid, - .card-grid--three { + .card-grid--three, + .form-grid { grid-template-columns: 1fr; } } + +@media (max-width: 640px) { + .page-shell { + width: min(1120px, calc(100% - 1rem)); + padding-top: 2rem; + } +}