diff --git a/Dockerfile b/Dockerfile index 8b7d62a..a33ef52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,12 +17,14 @@ COPY . . RUN if [ ! -d "dist" ] && [ ! -d "node_modules" ]; then npm install; fi # Build -RUN BUILD_MODE=$([ "$NODE_ENV" = "prod" ] && echo "dynmaic-prod" || echo "dynamic") && \ +RUN BUILD_MODE=$([ "$NODE_ENV" = "prod" ] && echo "prod" || echo "dev") && \ if [ ! -d "dist" ]; then npm run "build:$BUILD_MODE"; fi # Use Nginx to serve FROM nginx:1.20-alpine COPY --from=build /app/dist /usr/share/nginx/html -COPY docker/robots.txt /usr/share/nginx/html/robots.txt -COPY docker/nginx.conf /etc/nginx/nginx.conf +COPY docker/config/robots.txt /usr/share/nginx/html/robots.txt +COPY docker/config/nginx.conf /etc/nginx/nginx.conf +COPY docker/scripts/setup-environment.sh /docker-entrypoint.d/setup-environment.sh +RUN chmod +x /docker-entrypoint.d/setup-environment.sh EXPOSE 80 diff --git a/docker/nginx.conf b/docker/config/nginx.conf similarity index 100% rename from docker/nginx.conf rename to docker/config/nginx.conf diff --git a/docker/robots.txt b/docker/config/robots.txt similarity index 100% rename from docker/robots.txt rename to docker/config/robots.txt diff --git a/docker/scripts/setup-environment.sh b/docker/scripts/setup-environment.sh new file mode 100644 index 0000000..6556fd8 --- /dev/null +++ b/docker/scripts/setup-environment.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +if [ -n "$ANALYTICS" ]; then sed -i -e "s/:[[:space:]]\?['\"]{{ANALYTICS}}['\"]/:'$ANALYTICS'/g" /usr/share/nginx/html/main*.js; fi diff --git a/package-lock.json b/package-lock.json index 53fe186..ade76a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@angular/router": "^14.2.0", "bootstrap": "^5.2.1", "jquery": "^3.6.1", + "ngx-google-analytics": "^14.0.1", "rxjs": "~7.5.0", "tslib": "^2.3.0", "webstorage-decorators": "^4.2.0", @@ -7891,6 +7892,18 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/ngx-google-analytics": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/ngx-google-analytics/-/ngx-google-analytics-14.0.1.tgz", + "integrity": "sha512-PfOtnshSyq15EKevKlFW9IRgH+dTtPG4Q9HJYksuRNYDzjce0eqK3Bf6hz0tAZdyqbzTCyx5g+NgWBfpqQfb2w==", + "dependencies": { + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@angular/common": ">=12.0.0", + "@angular/core": ">=12.0.0" + } + }, "node_modules/nice-napi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", @@ -17380,6 +17393,14 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "ngx-google-analytics": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/ngx-google-analytics/-/ngx-google-analytics-14.0.1.tgz", + "integrity": "sha512-PfOtnshSyq15EKevKlFW9IRgH+dTtPG4Q9HJYksuRNYDzjce0eqK3Bf6hz0tAZdyqbzTCyx5g+NgWBfpqQfb2w==", + "requires": { + "tslib": "^2.4.0" + } + }, "nice-napi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", diff --git a/package.json b/package.json index dcb1c85..736d2d9 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@angular/router": "^14.2.0", "bootstrap": "^5.2.1", "jquery": "^3.6.1", + "ngx-google-analytics": "^14.0.1", "rxjs": "~7.5.0", "tslib": "^2.3.0", "webstorage-decorators": "^4.2.0", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 367e6a3..74cf862 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,12 +1,16 @@ -import { NgModule } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; -import { AppRouting } from './app.routing'; +import {NgModule} from '@angular/core'; +import {BrowserModule} from '@angular/platform-browser'; +import {NgxGoogleAnalyticsModule} from 'ngx-google-analytics'; +import {environment} from '../environments/environment'; +import {AppRouting} from './app.routing'; import {BannerComponent} from './components/banner/banner.component'; import {FooterComponent} from './components/footer/footer.component'; +import {LogoComponent} from './components/logo/logo.component'; import {NavbarComponent} from './components/navbar/navbar.component'; -import { AppComponent } from './containers/app/app.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import {AppComponent} from './containers/app/app.component'; +import {BrowserAnimationsModule } from '@angular/platform-browser/animations'; import {MaterialModule} from './material.module'; +import {PrelaodService} from './services/prelaod.service'; import {FourOFourComponent} from './views/404/404.component'; import {AboutComponent} from './views/about/about.component'; import {AestivaComponent} from './views/events/aestiva/aestiva.component'; @@ -18,32 +22,40 @@ import {DrillComponent} from './views/reenact/drill/drill.component'; import {GettingStartedComponent} from './views/reenact/getting-started/getting-started.component'; import {RulesComponent} from './views/reenact/rules/rules.component'; -export const APP_COMPONENTS = [ +export const APP_COMPONENTS: any[] = [ AboutComponent, AestivaComponent, AppComponent, BannerComponent, CalendarComponent, DrillComponent, - GettingStartedComponent, - HibernaComponent, FooterComponent, FourOFourComponent, GalleryComponent, + GettingStartedComponent, + HibernaComponent, HomeComponent, + LogoComponent, NavbarComponent, RulesComponent ] +export const APP_IMPORTS: any[] = [ + AppRouting, + BrowserAnimationsModule, + BrowserModule, + MaterialModule +] + +if(environment.analytics && (environment.analytics) != '{{ANALYTICS}}') + APP_IMPORTS.push(NgxGoogleAnalyticsModule.forRoot(environment.analytics)); + @NgModule({ declarations: APP_COMPONENTS, - imports: [ - BrowserModule, - AppRouting, - BrowserAnimationsModule, - MaterialModule - ], + imports: APP_IMPORTS, providers: [], bootstrap: [AppComponent] }) -export class AppModule { } +export class AppModule { + constructor(preload: PrelaodService) { } +} diff --git a/src/app/app.routing.ts b/src/app/app.routing.ts index b46a10a..2d097cd 100644 --- a/src/app/app.routing.ts +++ b/src/app/app.routing.ts @@ -13,15 +13,15 @@ import {RulesComponent} from './views/reenact/rules/rules.component'; const routes: Routes = [ {path: '', pathMatch: 'full', component: HomeComponent}, - {path: 'about', component: AboutComponent}, - {path: 'drill', component: DrillComponent}, - {path: 'events/aestiva', component: AestivaComponent}, - {path: 'events/hiberna', component: HibernaComponent}, - {path: 'events/calendar', component: CalendarComponent}, - {path: 'gallery', component: GalleryComponent}, - {path: 'getting-started', component: GettingStartedComponent}, - {path: 'rules', component: RulesComponent}, - {path: '**', component: FourOFourComponent} + {path: 'about', component: AboutComponent, data: {title: 'About'}}, + {path: 'drill', component: DrillComponent, data: {title: 'Drill Commands'}}, + {path: 'events/aestiva', component: AestivaComponent, data: {title: 'Castra Aestiva'}}, + {path: 'events/hiberna', component: HibernaComponent, data: {title: 'Castra Hiberna'}}, + {path: 'events/calendar', component: CalendarComponent, data: {title: 'Calendar'}}, + {path: 'gallery', component: GalleryComponent, data: {title: 'Gallery'}}, + {path: 'getting-started', component: GettingStartedComponent, data: {title: 'Getting Started'}}, + {path: 'rules', component: RulesComponent, data: {title: 'Rules & Regulations'}}, + {path: '**', component: FourOFourComponent, data: {title: '404'}} ]; @NgModule({ diff --git a/src/app/components/logo/logo.component.html b/src/app/components/logo/logo.component.html new file mode 100644 index 0000000..95142c8 --- /dev/null +++ b/src/app/components/logo/logo.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/components/logo/logo.component.scss b/src/app/components/logo/logo.component.scss new file mode 100644 index 0000000..d11de34 --- /dev/null +++ b/src/app/components/logo/logo.component.scss @@ -0,0 +1,26 @@ +//.logo { +// .expandable { +// width: 0; +// } +// +// &.expanded { +// .expandable { +// width: auto; +// } +// } +// +// .logo-segment { +// font-family: Arial, sans-serif !important; +// overflow: hidden; +// padding: 0.3em 0; +// width: auto; +// } +// +// .logo-dots { +// width: 30px; +// } +// +// .logo-footer { +// transform: translate(-15px, -8px); +// } +//} diff --git a/src/app/components/logo/logo.component.ts b/src/app/components/logo/logo.component.ts new file mode 100644 index 0000000..0ff1db3 --- /dev/null +++ b/src/app/components/logo/logo.component.ts @@ -0,0 +1,35 @@ +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {animate, state, style, transition, trigger} from '@angular/animations'; +import {Observable, timer} from 'rxjs'; +import {map} from 'rxjs/operators'; + +@Component({ + selector: 'xxx-logo', + templateUrl: './logo.component.html', + animations: [ + trigger('slide', [ + state('expand', style({width: '*'})), + state('shrink', style({width: '0px'})), + transition('* => *', [animate('0.5s')]) + ]), + trigger('margin', [ + state('expand', style({marginLeft: '0.25rem'})), + state('shrink', style({marginLeft: '0'})), + transition('* => *', [animate('0.5s')]) + ]) + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class LogoComponent { + @Input() expand = true; + @Input() loading = false; + @Input() loadingText = ''; + + dots: Observable; + + constructor() { + this.dots = timer(0, 1000).pipe(map(i => { + return Array(i % 4).fill('.').join(''); + })); + } +} diff --git a/src/app/components/navbar/navbar.component.html b/src/app/components/navbar/navbar.component.html index 910c5ef..ce4aabb 100644 --- a/src/app/components/navbar/navbar.component.html +++ b/src/app/components/navbar/navbar.component.html @@ -3,7 +3,7 @@
SPQR -
LEGIO · XXX
+
diff --git a/src/app/components/navbar/navbar.component.ts b/src/app/components/navbar/navbar.component.ts index 36a8a7d..cb88198 100644 --- a/src/app/components/navbar/navbar.component.ts +++ b/src/app/components/navbar/navbar.component.ts @@ -2,6 +2,7 @@ import {AfterViewInit, Component, EventEmitter, Input, OnDestroy, Output} from ' import {ActivatedRoute, NavigationEnd, Router} from '@angular/router'; import {combineLatest, filter, Subscription} from 'rxjs'; import {NAVIGATION} from '../../misc/navigation'; +import {BreakpointService} from '../../services/breakpoint.service'; @Component({ selector: 'xxx-navbar', @@ -20,7 +21,7 @@ export class NavbarComponent implements AfterViewInit, OnDestroy { @Output() hamburgerClick = new EventEmitter(); - constructor(private route: ActivatedRoute, private router: Router) { } + constructor(private route: ActivatedRoute, private router: Router, public breakpoint: BreakpointService) { } ngAfterViewInit() { this.sub = combineLatest([this.router.events.pipe(filter(e => e instanceof NavigationEnd)), this.route.fragment]).subscribe(([url, frag]) => { diff --git a/src/app/containers/app/app.component.ts b/src/app/containers/app/app.component.ts index 1140d68..8b21c22 100644 --- a/src/app/containers/app/app.component.ts +++ b/src/app/containers/app/app.component.ts @@ -1,7 +1,7 @@ -import {BreakpointObserver} from '@angular/cdk/layout'; import { Component } from '@angular/core'; -import {ActivatedRoute, NavigationEnd, Router} from '@angular/router'; -import {filter} from 'rxjs'; +import {NavigationEnd, Router} from '@angular/router'; +import {combineLatest, filter, Subscription} from 'rxjs'; +import {BreakpointService} from '../../services/breakpoint.service'; @Component({ selector: 'app-root', @@ -9,15 +9,22 @@ import {filter} from 'rxjs'; styleUrls: ['./app.component.scss'] }) export class AppComponent { + private sub?: Subscription; + mobile = false; open = false; - constructor(private router: Router, route: ActivatedRoute, breakpointObserver: BreakpointObserver) { - router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe(() => this.open = false); - breakpointObserver.observe(['(max-width: 750px)']).subscribe(result => { - this.mobile = result.matches; + constructor(private breakpoint: BreakpointService, private router: Router) { + this.sub = combineLatest([ + router.events.pipe(filter(event => event instanceof NavigationEnd)), + breakpoint.isMobile$ + ]).subscribe(([event, mobile]) => { + this.mobile = mobile; this.open = !this.mobile; }) } + ngOnDestroy() { + if(this.sub) this.sub.unsubscribe(); + } } diff --git a/src/app/services/analytics.service.ts b/src/app/services/analytics.service.ts new file mode 100644 index 0000000..9c4c37e --- /dev/null +++ b/src/app/services/analytics.service.ts @@ -0,0 +1,33 @@ +import {Injectable, OnDestroy, Optional} from '@angular/core'; +import {NavigationEnd, Router} from '@angular/router'; +import {GoogleAnalyticsService} from 'ngx-google-analytics'; +import {combineLatest, Subscription} from 'rxjs'; +import {filter} from 'rxjs/operators'; +import {TitleService} from './title.service'; + +@Injectable({providedIn: 'root'}) +export class AnalyticsService implements OnDestroy { + private sub?: Subscription; + + constructor(@Optional() private analyticsService: GoogleAnalyticsService, + private router: Router, + private title: TitleService + ) { + if(this.analyticsService) { + combineLatest([ + this.router.events.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) + ]).subscribe(([navigation]) => { + this.analyticsService.pageView(navigation.urlAfterRedirects, this.title.title); + }); + } + } + + log(action: string, category?: string, label?: string, value?: any, interaction?: boolean) { + if(!this.analyticsService) return; + return this.analyticsService.event(action, category, label, value, interaction); + } + + ngOnDestroy() { + if(this.sub) this.sub.unsubscribe(); + } +} diff --git a/src/app/services/breakpoint.service.ts b/src/app/services/breakpoint.service.ts new file mode 100644 index 0000000..dd9d7ce --- /dev/null +++ b/src/app/services/breakpoint.service.ts @@ -0,0 +1,15 @@ +import {Injectable} from '@angular/core'; +import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout'; +import {map, tap} from 'rxjs/operators'; + +@Injectable({providedIn: 'root'}) +export class BreakpointService { + private _isMobile?: boolean; + get isMobile() { return !!this.isMobile$; } + isMobile$ = this.breakpointObserver.observe([Breakpoints.XSmall]).pipe( + map(e => e.matches), + tap(e => this._isMobile = e) + ); + + constructor(private breakpointObserver: BreakpointObserver) { } +} diff --git a/src/app/services/prelaod.service.ts b/src/app/services/prelaod.service.ts new file mode 100644 index 0000000..b20e369 --- /dev/null +++ b/src/app/services/prelaod.service.ts @@ -0,0 +1,12 @@ +import {Injectable} from '@angular/core'; +import {AnalyticsService} from './analytics.service'; +import {BreakpointService} from './breakpoint.service'; +import {TitleService} from './title.service'; + +@Injectable({providedIn: 'root'}) +export class PrelaodService { + constructor(private analytics: AnalyticsService, + private breakpoint: BreakpointService, + private title: TitleService + ) { } +} diff --git a/src/app/services/title.service.ts b/src/app/services/title.service.ts new file mode 100644 index 0000000..56a3a5b --- /dev/null +++ b/src/app/services/title.service.ts @@ -0,0 +1,51 @@ +import {Injectable, OnDestroy} from '@angular/core'; +import {Title} from '@angular/platform-browser'; +import {ActivatedRoute, NavigationEnd, Router} from '@angular/router'; +import {filter} from 'rxjs/operators'; +import {Observable, Subscription} from 'rxjs'; + +@Injectable({providedIn: 'root'}) +export class TitleService implements OnDestroy { + private readonly orgTitle: string = 'LEGIO · XXX'; + + private routeSub?: Subscription; + private titleSub?: Subscription; + + get title(): string { + const title = this._title.getTitle(); + if(title.includes(this.orgTitle) && title.length > this.orgTitle.length) + return title.substring(this.orgTitle.length); + return this._title.getTitle(); + } + set title(title: string | null) { + if(!title) this._title.setTitle(this.orgTitle); + else this._title.setTitle(`${this.orgTitle} | ${title}`); +} + + constructor(private router: Router, + private route: ActivatedRoute, + private _title: Title + ) { + // this.orgTitle = this.title; // Hardcoding because HMR breaks this + this.routeSub = this.router.events + .pipe(filter(e => e instanceof NavigationEnd)) + .subscribe(() => this.getTitleFromRoute()); + this.getTitleFromRoute(); + } + + private async getTitleFromRoute() { + if(this.titleSub) this.titleSub.unsubscribe(); + let route = this.route, title = route.snapshot.data['title']; + while (route.firstChild) { + route = route.firstChild; + if(route.snapshot.data['title']) title = route.snapshot.data['title']; + } + if(title instanceof Observable) { this.titleSub = title.subscribe(t => this.title = t); } + else { this.title = title; } + } + + ngOnDestroy() { + if(this.routeSub) this.routeSub.unsubscribe(); + if(this.titleSub) this.titleSub.unsubscribe(); + } +} diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 3612073..473e53f 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,3 +1,4 @@ export const environment = { - production: true + analytics: 'G-7HLT4FQY9V', + production: true }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index ffe8aed..43bace1 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,3 +1,4 @@ export const environment = { - production: false + analytics: false, + production: false };