diff --git a/{{cookiecutter.slug}}/frontend.angular/README.md b/{{cookiecutter.slug}}/frontend.angular/README.md index f7950f6..f9a8dc4 100644 --- a/{{cookiecutter.slug}}/frontend.angular/README.md +++ b/{{cookiecutter.slug}}/frontend.angular/README.md @@ -12,6 +12,8 @@ Run `ng generate component component-name` to generate a new component. You can Run `yarn build` to build the project. The build artifacts will be stored in the `dist/` directory. +With SSR (Server Side Rendering) all the routes will be pre-compiled. Because of this the backend server should already be running before building the frontend! + ## Running unit tests Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). diff --git a/{{cookiecutter.slug}}/frontend.angular/angular.overwrite.json b/{{cookiecutter.slug}}/frontend.angular/angular.overwrite.json index a71f74b..4b2e28a 100644 --- a/{{cookiecutter.slug}}/frontend.angular/angular.overwrite.json +++ b/{{cookiecutter.slug}}/frontend.angular/angular.overwrite.json @@ -31,9 +31,37 @@ "src/favicon.png", "src/favicon.svg", "src/assets" + ], + "styles": [ + "src/styles.scss", + { + "input": "src/dark.scss", + "bundleName": "dark", + "inject": false + }, + { + "input": "src/light.scss", + "bundleName": "light", + "inject": false + } ] }, "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "2mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "sourceMap": true + }, {% set comma = joiner(",") %} {%- for loc in localizations %} {%- set code, name = loc.split(':') %} @@ -50,6 +78,9 @@ "defaultConfiguration": "production" }, "serve": { + "options": { + "proxyConfig": "../proxy.conf.json" + }, "configurations": { {% set comma = joiner(",") %} {%- for loc in localizations %} diff --git a/{{cookiecutter.slug}}/frontend.angular/package.overwrite.json b/{{cookiecutter.slug}}/frontend.angular/package.overwrite.json index 6cff5c8..3ac9464 100644 --- a/{{cookiecutter.slug}}/frontend.angular/package.overwrite.json +++ b/{{cookiecutter.slug}}/frontend.angular/package.overwrite.json @@ -6,12 +6,12 @@ "build": "ng build --base-href=/static/ --localize", "i18n": "ng extract-i18n --output-path locale", "preserve": "yarn prebuild", - "serve": "ng serve --proxy-config ../proxy.conf.json", + "serve": "ng serve", "start": "yarn serve", {% set localizations = cookiecutter.localizations.split(',') %} {%- for loc in localizations %} {%- set code, name = loc.split(':') %} - "serve:{{code}}": "ng serve --proxy-config ../proxy.conf.json --configuration={{code}}", + "serve:{{code}}": "ng serve --configuration={{code}}", {%- endfor %} "stop": "lsof -t -i tcp:{{cookiecutter.frontend_port}} | xargs kill -9 || echo \"not running\"", "pretest": "yarn prebuild", @@ -25,9 +25,8 @@ "@fortawesome/free-solid-svg-icons": "^6.5.2", "@ngrx/effects": "^17.2.0", "@ngrx/store": "^17.2.0", - "bulma": "^0.9.1", + "bulma": "^1.0.1", "colors": "^1.4.0", - "primeicons": "^7.0.0", "primeng": "^17.18.0" }, "devDependencies": { diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/app.component.ts b/{{cookiecutter.slug}}/frontend.angular/src/app/app.component.ts index 300af47..09267a8 100644 --- a/{{cookiecutter.slug}}/frontend.angular/src/app/app.component.ts +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/app.component.ts @@ -1,7 +1,9 @@ -import { Component } from '@angular/core'; +import { Component, Inject, afterRender } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; import { RouterOutlet } from '@angular/router'; import { MenuComponent } from './menu/menu.component'; import { FooterComponent } from './footer/footer.component'; +import { DarkModeService } from './services/dark-mode.service'; @Component({ selector: '{{cookiecutter.app_prefix}}-root', @@ -12,4 +14,21 @@ import { FooterComponent } from './footer/footer.component'; }) export class AppComponent { title = '{{cookiecutter.project_title}}'; + + constructor(@Inject(DOCUMENT) private document: Document, private darkModeService: DarkModeService) { + // Using the DOM API to only render on the browser instead of on the server + afterRender(() => { + const style = this.document.createElement('link'); + style.rel = 'stylesheet'; + this.document.head.append(style); + + this.darkModeService.theme$.subscribe(theme => { + this.document.documentElement.classList.remove(theme === 'dark' ? 'theme-light' : 'theme-dark'); + this.document.documentElement.classList.add('theme-' + theme); + + style.href = `${theme}.css`; + }); + }); + } + } diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/app.config.server.ts b/{{cookiecutter.slug}}/frontend.angular/src/app/app.config.server.ts new file mode 100644 index 0000000..f010bfa --- /dev/null +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/app.config.server.ts @@ -0,0 +1,12 @@ +import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; +import { provideServerRendering } from '@angular/platform-server'; +import { BACKEND_URL, appConfig } from './app.config'; + +const serverConfig: ApplicationConfig = { + providers: [ + provideServerRendering(), + { provide: BACKEND_URL, useValue: 'http://localhost:8000' } + ] +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/app.config.ts b/{{cookiecutter.slug}}/frontend.angular/src/app/app.config.ts index ddc8f7c..4d097a8 100644 --- a/{{cookiecutter.slug}}/frontend.angular/src/app/app.config.ts +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/app.config.ts @@ -1,28 +1,37 @@ -import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { ApplicationConfig, InjectionToken, provideZoneChangeDetection } from '@angular/core'; import { APP_BASE_HREF } from '@angular/common'; -import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http'; +import { provideHttpClient, withFetch, withXsrfConfiguration } from '@angular/common/http'; import { provideClientHydration } from '@angular/platform-browser'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +export const BACKEND_URL = new InjectionToken('BackendUrl'); + export const appConfig: ApplicationConfig = { providers: [ provideAnimations(), provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideClientHydration(), - provideHttpClient(withXsrfConfiguration({ - cookieName: 'csrftoken', - headerName: 'X-CSRFToken' - })), + provideHttpClient( + withFetch(), + withXsrfConfiguration({ + cookieName: 'csrftoken', + headerName: 'X-CSRFToken' + })), // The language is used as the base_path for finding the right // static-files. For example /nl/static/main.js // However the routing is done from a base path starting from // the root e.g. /home // The server should then switch index.html based on a language // cookie with a fallback to Dutch e.g. /nl/static/index.html - { provide: APP_BASE_HREF, useValue: '/' } + { provide: APP_BASE_HREF, useValue: '/' }, + // because proxy doesn't work for SSR, support a wonky workaround + // by manually specifying the URL where the backend is running + // https://github.com/angular/angular-cli/issues/27144 + // By default it is empty, because in the browser this isn't needed + { provide: BACKEND_URL, useValue: '' } ] }; diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/dark-mode-toggle/dark-mode-toggle.component.html b/{{cookiecutter.slug}}/frontend.angular/src/app/dark-mode-toggle/dark-mode-toggle.component.html new file mode 100644 index 0000000..5823fd2 --- /dev/null +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/dark-mode-toggle/dark-mode-toggle.component.html @@ -0,0 +1,16 @@ +

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

diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/dark-mode-toggle/dark-mode-toggle.component.scss b/{{cookiecutter.slug}}/frontend.angular/src/app/dark-mode-toggle/dark-mode-toggle.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/dark-mode-toggle/dark-mode-toggle.component.spec.ts b/{{cookiecutter.slug}}/frontend.angular/src/app/dark-mode-toggle/dark-mode-toggle.component.spec.ts new file mode 100644 index 0000000..2a3a0c6 --- /dev/null +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/dark-mode-toggle/dark-mode-toggle.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DarkModeToggleComponent } from './dark-mode-toggle.component'; + +describe('DarkModeToggleComponent', () => { + let component: DarkModeToggleComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DarkModeToggleComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DarkModeToggleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/dark-mode-toggle/dark-mode-toggle.component.ts b/{{cookiecutter.slug}}/frontend.angular/src/app/dark-mode-toggle/dark-mode-toggle.component.ts new file mode 100644 index 0000000..ae19350 --- /dev/null +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/dark-mode-toggle/dark-mode-toggle.component.ts @@ -0,0 +1,32 @@ +import { Component, OnDestroy } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons'; +import { Subscription } from 'rxjs'; +import { DarkModeService } from '../services/dark-mode.service'; + +@Component({ + selector: '{{cookiecutter.app_prefix}}-dark-mode-toggle', + standalone: true, + imports: [FontAwesomeModule], + templateUrl: './dark-mode-toggle.component.html', + styleUrl: './dark-mode-toggle.component.scss' +}) +export class DarkModeToggleComponent implements OnDestroy { + private subscriptions!: Subscription[]; + faSun = faSun; + faMoon = faMoon; + dark = false; + + constructor(private darkModeService: DarkModeService) { + this.subscriptions = [ + this.darkModeService.theme$.subscribe(theme => this.dark = theme === 'dark')]; + } + + toggle() { + this.darkModeService.toggle(); + } + + ngOnDestroy(): void { + this.subscriptions.forEach(s => s.unsubscribe()); + } +} diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/footer/footer.component.html b/{{cookiecutter.slug}}/frontend.angular/src/app/footer/footer.component.html index 69c3229..0329a31 100644 --- a/{{cookiecutter.slug}}/frontend.angular/src/app/footer/footer.component.html +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/footer/footer.component.html @@ -6,16 +6,18 @@

{{cookiecutter.project_title}}

- Source code (BSD 3-Clause License) + Source code (BSD 3-Clause License)

- Version: {{"{{environment.version}}"}} (source) + Version: {{"{{environment.version}}"}} ({{"{{buildTime}}"}})

diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/footer/footer.component.scss b/{{cookiecutter.slug}}/frontend.angular/src/app/footer/footer.component.scss index 5b60f37..983717a 100644 --- a/{{cookiecutter.slug}}/frontend.angular/src/app/footer/footer.component.scss +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/footer/footer.component.scss @@ -1,3 +1,4 @@ -.lab-logo { - max-width: 400px; +.centre-logo { + width: 450px; + max-width: calc(100vw - 7rem); } diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/footer/footer.component.ts b/{{cookiecutter.slug}}/frontend.angular/src/app/footer/footer.component.ts index f5f737e..60daf2c 100644 --- a/{{cookiecutter.slug}}/frontend.angular/src/app/footer/footer.component.ts +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/footer/footer.component.ts @@ -1,5 +1,8 @@ -import { Component } from '@angular/core'; +import { Component, Inject, LOCALE_ID, OnDestroy } from '@angular/core'; +import { formatDate } from '@angular/common'; +import { Subscription } from 'rxjs'; import { environment } from '../../environments/environment'; +import { DarkModeService } from '../services/dark-mode.service'; @Component({ selector: '{{cookiecutter.app_prefix}}-footer', @@ -7,9 +10,20 @@ import { environment } from '../../environments/environment'; styleUrls: ['./footer.component.scss'], standalone: true }) -export class FooterComponent { +export class FooterComponent implements OnDestroy { environment = environment; + buildTime!: string; + dark = false; + subscriptions!: Subscription[]; - constructor() { } + constructor(@Inject(LOCALE_ID) localeId: string, darkModeService: DarkModeService) { + this.buildTime = formatDate(new Date(environment.buildTime), $localize`:@@dateFormat:MMMM dd, yyyy`, localeId); + this.subscriptions = [ + darkModeService.theme$.subscribe(theme => this.dark = theme === 'dark')]; + } + + ngOnDestroy(): void { + this.subscriptions.forEach(s => s.unsubscribe()); + } } diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/menu/menu.component.html b/{{cookiecutter.slug}}/frontend.angular/src/app/menu/menu.component.html index 5493c67..eac53c2 100644 --- a/{{cookiecutter.slug}}/frontend.angular/src/app/menu/menu.component.html +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/menu/menu.component.html @@ -21,7 +21,9 @@ Home - diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/menu/menu.component.ts b/{{cookiecutter.slug}}/frontend.angular/src/app/menu/menu.component.ts index 7416574..85eb8fd 100644 --- a/{{cookiecutter.slug}}/frontend.angular/src/app/menu/menu.component.ts +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/menu/menu.component.ts @@ -1,9 +1,10 @@ -import { Component, LOCALE_ID, Inject, OnInit, NgZone } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { Component, LOCALE_ID, Inject, OnInit, NgZone, afterRender } from '@angular/core'; +import { CommonModule, DOCUMENT } from '@angular/common'; import { RouterLink } from '@angular/router'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { faGlobe, faSync } from '@fortawesome/free-solid-svg-icons'; import { animations, showState } from '../animations'; +import { DarkModeToggleComponent } from '../dark-mode-toggle/dark-mode-toggle.component'; import { LanguageInfo, LanguageService } from '../services/language.service'; @Component({ @@ -12,10 +13,10 @@ import { LanguageInfo, LanguageService } from '../services/language.service'; templateUrl: './menu.component.html', styleUrls: ['./menu.component.scss'], standalone: true, - imports: [CommonModule, RouterLink, FontAwesomeModule] + imports: [CommonModule, RouterLink, FontAwesomeModule, DarkModeToggleComponent] }) export class MenuComponent implements OnInit { - burgerShow: showState; + burgerShow: showState = 'show'; burgerActive = false; currentLanguage: string; loading = false; @@ -29,13 +30,18 @@ export class MenuComponent implements OnInit { languages?: LanguageInfo['supported']; constructor( + @Inject(DOCUMENT) private document: Document, @Inject(LOCALE_ID) private localeId: string, private ngZone: NgZone, private languageService: LanguageService) { - // Window is undefined on the server - const isDesktop = typeof window !== "undefined" ? window.matchMedia("screen and (min-width: 1024px)").matches : true; - this.burgerShow = isDesktop ? 'show' : 'hide'; this.currentLanguage = this.localeId; + + // Using the DOM API to only render on the browser instead of on the server + afterRender(() => { + const window = this.document.defaultView; + const isDesktop = window ? window.matchMedia("screen and (min-width: 1024px)").matches : true; + this.burgerShow = isDesktop ? 'show' : 'hide'; + }); } async ngOnInit(): Promise { diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/services/backend.service.ts b/{{cookiecutter.slug}}/frontend.angular/src/app/services/backend.service.ts index 9423437..5caf9f9 100644 --- a/{{cookiecutter.slug}}/frontend.angular/src/app/services/backend.service.ts +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/services/backend.service.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@angular/core'; +import { Inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { lastValueFrom } from 'rxjs'; import { ConfigService } from './config.service'; +import { BACKEND_URL } from '../app.config'; @Injectable({ @@ -10,7 +11,8 @@ import { ConfigService } from './config.service'; export class BackendService { protected apiUrl: Promise | null = null; - constructor(protected config: ConfigService, protected http: HttpClient) { } + constructor(protected config: ConfigService, protected http: HttpClient, @Inject(BACKEND_URL) private backendUrl: string) { + } /** * Collect JSON from an specific url. @@ -32,10 +34,11 @@ export class BackendService { getApiUrl(): Promise { if (!this.apiUrl) { - return this.config.get().then(config => config.backendUrl); - } else { - return Promise.resolve(this.apiUrl); + this.apiUrl = this.config.get().then(config => this.backendUrl + config.backendUrl); } + + return this.apiUrl; + } protected handleError(error: any): Promise { diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/services/dark-mode.service.spec.ts b/{{cookiecutter.slug}}/frontend.angular/src/app/services/dark-mode.service.spec.ts new file mode 100644 index 0000000..d1873d7 --- /dev/null +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/services/dark-mode.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { DarkModeService } from './dark-mode.service'; + +describe('DarkModeService', () => { + let service: DarkModeService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(DarkModeService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/services/dark-mode.service.ts b/{{cookiecutter.slug}}/frontend.angular/src/app/services/dark-mode.service.ts new file mode 100644 index 0000000..ca2b3d6 --- /dev/null +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/services/dark-mode.service.ts @@ -0,0 +1,87 @@ +import { DOCUMENT } from '@angular/common'; +import { Inject, Injectable, afterRender } from '@angular/core'; +import { BehaviorSubject, Observable, distinctUntilChanged } from 'rxjs'; + +/** + * Bulma theme + */ +type Theme = 'dark' | 'light'; + +@Injectable({ + providedIn: 'root' +}) +export class DarkModeService { + private initialized = false; + + /** + * Whether the user's system is set to use dark mode. + */ + private system: Theme = 'light'; + + private theme!: BehaviorSubject; + theme$: Observable; + + + constructor(@Inject(DOCUMENT) private document: Document) { + this.theme = new BehaviorSubject(this.get() ?? this.system); + this.theme$ = this.theme.pipe(distinctUntilChanged()); + afterRender(() => { + this.initialize(); + }); + } + + private initialize() { + if (this.initialized) { + return; + } + + this.initialized = true; + const window = this.document.defaultView; + const system = + window?.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches + ? 'dark' + : 'light'; + + if (this.system !== system) { + this.system = system; + this.theme.next(this.get() ?? this.system); + } + } + + /** + * Gets the user's theme or null if they did not set anything + * @returns + */ + private get(): Theme | null { + if (typeof localStorage == 'undefined') { + // localStorage is undefined on the server + return null; + } + return localStorage.getItem('theme'); + } + + /** + * Sets the user's theme + * @param value user setting or null if it should depend on the system + */ + private set(value: Theme | null): void { + if (value == null) { + localStorage.removeItem('theme'); + } else { + localStorage.setItem('theme', value); + } + } + + toggle() { + const target: Theme = this.theme.value === 'dark' ? 'light' : 'dark'; + if (target === this.system) { + // restore to system setting - if the user might change that + // system's setting later on this application will follow + this.set(null); + } else { + this.set(target); + } + + this.theme.next(target); + } +} diff --git a/{{cookiecutter.slug}}/frontend.angular/src/app/services/language.service.ts b/{{cookiecutter.slug}}/frontend.angular/src/app/services/language.service.ts index 7ee353d..01984fd 100644 --- a/{{cookiecutter.slug}}/frontend.angular/src/app/services/language.service.ts +++ b/{{cookiecutter.slug}}/frontend.angular/src/app/services/language.service.ts @@ -1,6 +1,7 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { lastValueFrom } from 'rxjs'; +import { BackendService } from './backend.service'; export interface LanguageInfo { current: string; @@ -16,14 +17,14 @@ export interface LanguageInfo { export class LanguageService { baseApiUrl = '/api'; - constructor(private http: HttpClient) { + constructor(private http: HttpClient, private backendService: BackendService) { } async get(): Promise { - const response = await lastValueFrom(this.http.get<{ + const response: { current: string, supported: [string, string][] - }>(this.baseApiUrl + '/i18n/')); + } = await this.backendService.get('i18n') return { current: response.current, diff --git a/{{cookiecutter.slug}}/frontend.angular/src/assets/uu-cdh-dark.svg b/{{cookiecutter.slug}}/frontend.angular/src/assets/uu-cdh-dark.svg new file mode 100644 index 0000000..b8c394d --- /dev/null +++ b/{{cookiecutter.slug}}/frontend.angular/src/assets/uu-cdh-dark.svg @@ -0,0 +1,49 @@ + + \ No newline at end of file diff --git a/{{cookiecutter.slug}}/frontend.angular/src/assets/uu-cdh.svg b/{{cookiecutter.slug}}/frontend.angular/src/assets/uu-cdh.svg old mode 100755 new mode 100644 index dcd996d..9045621 --- a/{{cookiecutter.slug}}/frontend.angular/src/assets/uu-cdh.svg +++ b/{{cookiecutter.slug}}/frontend.angular/src/assets/uu-cdh.svg @@ -1,60 +1,60 @@ -