├── src ├── assets │ ├── .gitkeep │ ├── img │ │ ├── wb6.gif │ │ ├── logo.png │ │ ├── favicon.png │ │ ├── pwa-logo.png │ │ └── angular-pwa.png │ ├── icons │ │ ├── icon-72x72.png │ │ ├── icon-96x96.png │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ └── icon-512x512.png │ ├── app.webmanifest │ └── staticwebapps.config.json ├── 404.html ├── 404error.html ├── app │ ├── home │ │ ├── home.component.scss │ │ ├── home.component.spec.ts │ │ ├── home.component.html │ │ └── home.component.ts │ ├── post │ │ ├── post.component.scss │ │ ├── post.component.html │ │ ├── post.component.spec.ts │ │ └── post.component.ts │ ├── about │ │ ├── about.component.scss │ │ ├── about.component.html │ │ ├── about.component.ts │ │ └── about.component.spec.ts │ ├── posts │ │ ├── posts.component.scss │ │ ├── posts.component.html │ │ ├── posts.component.spec.ts │ │ └── posts.component.ts │ ├── post-tweet │ │ ├── post-tweet.component.css │ │ ├── post-tweet.component.html │ │ └── post-tweet.component.ts │ ├── cached-route │ │ ├── cached-route.component.css │ │ ├── cached-route.component.html │ │ └── cached-route.component.ts │ ├── shared │ │ ├── post-card │ │ │ ├── post-card.component.scss │ │ │ ├── post-card.component.ts │ │ │ ├── post-card.component.spec.ts │ │ │ └── post-card.component.html │ │ └── post-list │ │ │ ├── post-list.component.scss │ │ │ ├── post-list.component.ts │ │ │ ├── post-list.component.html │ │ │ └── post-list.component.spec.ts │ ├── non-cached-route │ │ ├── non-cached-route.component.css │ │ ├── non-cached-route.component.html │ │ └── non-cached-route.component.ts │ ├── push-subscription │ │ ├── push-subscription.component.css │ │ ├── push-subscription.component.html │ │ └── push-subscription.component.ts │ ├── settings.service.ts │ ├── config.service.ts │ ├── ghost.service.spec.ts │ ├── settings.service.spec.ts │ ├── ghost.service.ts │ ├── app-routing.module.ts │ ├── app-shell │ │ ├── app-shell.component.spec.ts │ │ ├── app-shell.component.scss │ │ ├── app-shell.component.ts │ │ └── app-shell.component.html │ ├── push-subscription.service.ts │ ├── api.service.ts │ └── app.module.ts ├── out-of-spa │ └── index.html ├── offline │ ├── offline.html │ └── offline.png ├── favicon.ico ├── styles.scss ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── main.ts ├── index.html ├── test.ts ├── sw-recipes.js ├── polyfills.ts └── service-worker.js ├── .firebaserc ├── netlify.toml ├── fiveserver.config.js ├── serve.json ├── .vscode └── settings.json ├── vercel.json ├── firebase.json ├── e2e ├── src │ ├── app.po.ts │ └── app.e2e-spec.ts ├── tsconfig.json └── protractor.conf.js ├── .editorconfig ├── tsconfig.app.json ├── tsconfig.spec.json ├── rollup.config.js ├── .github └── workflows │ ├── firebase-hosting-pull-request.yml │ ├── firebase-hosting-merge.yml │ └── azure-static-web-apps-black-beach-0a05a8c1e.yml ├── .browserslistrc ├── .gitignore ├── tsconfig.json ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── workbox-inject.js ├── karma.conf.js ├── README.md ├── package.json ├── tslint.json └── angular.json /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/404.html: -------------------------------------------------------------------------------- 1 | Real 404.html -------------------------------------------------------------------------------- /src/404error.html: -------------------------------------------------------------------------------- 1 | Real 404.html -------------------------------------------------------------------------------- /src/app/home/home.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/post/post.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/about/about.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/posts/posts.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/post-tweet/post-tweet.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/cached-route/cached-route.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/shared/post-card/post-card.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/shared/post-list/post-list.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/non-cached-route/non-cached-route.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/push-subscription/push-subscription.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/out-of-spa/index.html: -------------------------------------------------------------------------------- 1 | This file is not a part of SPA -------------------------------------------------------------------------------- /src/offline/offline.html: -------------------------------------------------------------------------------- 1 | We are offline and this resource was not in cache... -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "prog-web-news" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/*" 3 | to = "/index.html" 4 | status = 400 -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /fiveserver.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | port: 5000, 3 | root: "dist/prog-web-news", 4 | }; 5 | -------------------------------------------------------------------------------- /src/assets/img/wb6.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/img/wb6.gif -------------------------------------------------------------------------------- /src/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/img/logo.png -------------------------------------------------------------------------------- /src/offline/offline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/offline/offline.png -------------------------------------------------------------------------------- /src/assets/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/img/favicon.png -------------------------------------------------------------------------------- /src/assets/img/pwa-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/img/pwa-logo.png -------------------------------------------------------------------------------- /src/app/about/about.component.html: -------------------------------------------------------------------------------- 1 | This is a demo project by Maxim Salnikov -------------------------------------------------------------------------------- /src/assets/img/angular-pwa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/img/angular-pwa.png -------------------------------------------------------------------------------- /src/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /src/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /src/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /src/assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /src/assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /src/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /src/assets/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/icons/icon-384x384.png -------------------------------------------------------------------------------- /src/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webmaxru/prog-web-news/HEAD/src/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/**", "destination": "/index.html" }], 3 | "public": "dist/prog-web-news" 4 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveServer.settings.root": "dist/prog-web-news", 3 | "liveServer.settings.file": "index.html" 4 | } -------------------------------------------------------------------------------- /src/app/posts/posts.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/app/cached-route/cached-route.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

This is the route controlled by service worker

4 |
5 |
-------------------------------------------------------------------------------- /src/app/non-cached-route/non-cached-route.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

This is the route outside of service worker control

4 |
5 |
-------------------------------------------------------------------------------- /src/app/post/post.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | All posts 6 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "routes": [ 4 | { "handle": "filesystem" }, 5 | { "src": "/(?!assets/?)(.*)", "dest": "/index.html" }, 6 | { "src": "/(.*)", "status": 404, "dest": "/404error.html" } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "dist/prog-web-news", 4 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 5 | "rewrites": [ 6 | { 7 | "source": "!/assets/**", 8 | "destination": "/index.html" 9 | } 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/settings.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root', 5 | }) 6 | export class SettingsService { 7 | appTitle = 'Progressive Web News'; 8 | 9 | buildAppTitle(customPart: string) { 10 | return customPart + ' | ' + this.appTitle; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | html, body { height: 100%; } 4 | body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } 5 | 6 | a { 7 | color:#673ab7; 8 | } 9 | 10 | a:hover, a:active { 11 | color:rgba(103,58,183,0.5); 12 | } -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | async navigateTo(): Promise { 5 | return browser.get(browser.baseUrl); 6 | } 7 | 8 | async getTitleText(): Promise { 9 | return element(by.css('app-root .content span')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/app/shared/post-card/post-card.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-post-card', 5 | templateUrl: './post-card.component.html', 6 | styleUrls: ['./post-card.component.scss'] 7 | }) 8 | export class PostCardComponent { 9 | 10 | @Input() post; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../out-tsc/e2e", 6 | "module": "commonjs", 7 | "target": "es2018", 8 | "types": [ 9 | "jasmine", 10 | "node" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | config: { 4 | "DATA_API_URL": "https://pwa-workshop.azurewebsites.net", 5 | "PUSH_API_URL": "https://pwa-workshop.azurewebsites.net", 6 | "VAPID_PUBLIC_KEY": "BM88mSlUg4mvjcPK5QrzRfQzow91F47iEazCnoTBQ8Hv_AVrJviLcnrNumTK319qWOt43sgOzBJs6UrdOW5IxHg" 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/app/shared/post-list/post-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-post-list', 5 | templateUrl: './post-list.component.html', 6 | styleUrls: ['./post-list.component.scss'] 7 | }) 8 | export class PostListComponent { 9 | 10 | @Input() posts; 11 | @Input() showExcerpt:boolean = true; 12 | 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/app/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { environment } from './../environments/environment'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class ConfigService { 9 | private _config: any = environment.config; 10 | 11 | constructor() {} 12 | 13 | get(key: any) { 14 | return this._config[key]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/cached-route/cached-route.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-cached-route', 5 | templateUrl: './cached-route.component.html', 6 | styleUrls: ['./cached-route.component.css'] 7 | }) 8 | export class CachedRouteComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit() { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/app/non-cached-route/non-cached-route.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-non-cached-route', 5 | templateUrl: './non-cached-route.component.html', 6 | styleUrls: ['./non-cached-route.component.css'] 7 | }) 8 | export class NonCachedRouteComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit() { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /src/app/ghost.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { GhostService } from './ghost.service'; 4 | 5 | describe('GhostService', () => { 6 | let service: GhostService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(GhostService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/settings.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { SettingsService } from './settings.service'; 4 | 5 | describe('SettingsService', () => { 6 | let service: SettingsService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(SettingsService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve' 2 | import replace from 'rollup-plugin-replace' 3 | import { terser } from 'rollup-plugin-terser' 4 | 5 | export default { 6 | input: 'dist/prog-web-news/sw.js', 7 | output: { 8 | file: 'dist/prog-web-news/sw.js', 9 | format: 'iife' 10 | }, 11 | plugins: [ 12 | resolve(), 13 | replace({ 14 | 'process.env.NODE_ENV': JSON.stringify('development') 15 | }), 16 | terser() 17 | ] 18 | } -------------------------------------------------------------------------------- /src/app/ghost.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import GhostContentAPI from '@tryghost/content-api' 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class GhostService { 8 | 9 | ghostConnection; 10 | 11 | constructor() { 12 | 13 | this.ghostConnection = new GhostContentAPI({ 14 | url: 'https://progwebnews-app.azurewebsites.net', 15 | key: '7bcf4d0fbd1d518e7da4c74465', 16 | version: "v3" 17 | }); 18 | 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/shared/post-list/post-list.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ post.authors[0].name }} 4 |

5 | {{ post.title }} 6 |

7 |

8 | {{ post.custom_excerpt }} 9 |

10 |

11 | {{ post.published_at | date:'fullDate' }} 12 |

13 |
14 |
-------------------------------------------------------------------------------- /src/app/about/about.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Title } from '@angular/platform-browser'; 3 | import { SettingsService } from '../settings.service'; 4 | 5 | @Component({ 6 | selector: 'app-about', 7 | templateUrl: './about.component.html', 8 | styleUrls: ['./about.component.scss'], 9 | }) 10 | export class AboutComponent implements OnInit { 11 | constructor( 12 | private titleService: Title, 13 | private settingsService: SettingsService 14 | ) {} 15 | 16 | ngOnInit(): void { 17 | this.titleService.setTitle( 18 | this.settingsService.buildAppTitle('About this website') 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-pull-request.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on PR 5 | 'on': pull_request 6 | jobs: 7 | build_and_preview: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - run: npm ci && npm run build 12 | - uses: FirebaseExtended/action-hosting-deploy@v0 13 | with: 14 | repoToken: '${{ secrets.GITHUB_TOKEN }}' 15 | firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_PROG_WEB_NEWS }}' 16 | projectId: prog-web-news 17 | env: 18 | FIREBASE_CLI_PREVIEWS: hostingchannels 19 | -------------------------------------------------------------------------------- /src/app/home/home.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { HomeComponent } from './home.component'; 4 | 5 | describe('HomeComponent', () => { 6 | let component: HomeComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ HomeComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(HomeComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/post/post.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PostComponent } from './post.component'; 4 | 5 | describe('PostComponent', () => { 6 | let component: PostComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ PostComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(PostComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/about/about.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AboutComponent } from './about.component'; 4 | 5 | describe('AboutComponent', () => { 6 | let component: AboutComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ AboutComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(AboutComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/posts/posts.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PostsComponent } from './posts.component'; 4 | 5 | describe('PostsComponent', () => { 6 | let component: PostsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ PostsComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(PostsComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/push-subscription/push-subscription.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | Push and notification use different, but complementary, APIs: push is invoked when a server supplies information 5 | to a service 6 | worker; a notification is the action of a service worker or web page script showing information to a user. 7 |

8 |
9 | 10 | 13 | 16 | 17 | 18 |
-------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ProgWebNews 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | import { browser, logging } from 'protractor'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', async () => { 12 | await page.navigateTo(); 13 | expect(await page.getTitleText()).toEqual('prog-web-news app is running!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain(jasmine.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. 18 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-merge.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on merge 5 | 'on': 6 | push: 7 | branches: 8 | - main 9 | jobs: 10 | build_and_deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - run: npm ci && npm run build 15 | - uses: FirebaseExtended/action-hosting-deploy@v0 16 | with: 17 | repoToken: '${{ secrets.GITHUB_TOKEN }}' 18 | firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_PROG_WEB_NEWS }}' 19 | channelId: live 20 | projectId: prog-web-news 21 | env: 22 | FIREBASE_CLI_PREVIEWS: hostingchannels 23 | -------------------------------------------------------------------------------- /src/app/shared/post-card/post-card.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PostCardComponent } from './post-card.component'; 4 | 5 | describe('PostCardComponent', () => { 6 | let component: PostCardComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ PostCardComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(PostCardComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/shared/post-list/post-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PostListComponent } from './post-list.component'; 4 | 5 | describe('PostListComponent', () => { 6 | let component: PostListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ PostListComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(PostListComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/shared/post-card/post-card.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
8 | {{ post.authors[0].name }} 9 | {{ post.published_at | date:'fullDate' }} 10 |
11 | {{ post.title }} 17 | 18 | {{ post.title }} 19 | 20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: { 11 | context(path: string, deep?: boolean, filter?: RegExp): { 12 | keys(): string[]; 13 | (id: string): T; 14 | }; 15 | }; 16 | 17 | // First, initialize the Angular testing environment. 18 | getTestBed().initTestEnvironment( 19 | BrowserDynamicTestingModule, 20 | platformBrowserDynamicTesting() 21 | ); 22 | // Then we find all the tests. 23 | const context = require.context('./', true, /\.spec\.ts$/); 24 | // And load the modules. 25 | context.keys().map(context); 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.angular/cache 36 | /.sass-cache 37 | /connect.lock 38 | /coverage 39 | /libpeerconnection.log 40 | npm-debug.log 41 | yarn-error.log 42 | testem.log 43 | /typings 44 | 45 | # System Files 46 | .DS_Store 47 | Thumbs.db 48 | -------------------------------------------------------------------------------- /src/app/post-tweet/post-tweet.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | sync 4 | Background Sync 5 | Defer actions until the user has stable connectivity 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 |
14 | Message is required 15 |
16 |
17 | 18 | 19 | 20 |
21 | 22 |
23 | 24 |
25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": false, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "downlevelIteration": true, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "es2017", 20 | "module": "es2020", 21 | "lib": [ 22 | "es2020", 23 | "dom" 24 | ] 25 | }, 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.202.5/containers/javascript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="16-buster" 5 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node modules 16 | RUN su node -c "npm install -g serve" 17 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | config: { 8 | "DATA_API_URL": "https://pwa-workshop.azurewebsites.net", 9 | "PUSH_API_URL": "https://pwa-workshop.azurewebsites.net", 10 | "VAPID_PUBLIC_KEY": "BM88mSlUg4mvjcPK5QrzRfQzow91F47iEazCnoTBQ8Hv_AVrJviLcnrNumTK319qWOt43sgOzBJs6UrdOW5IxHg" 11 | } 12 | }; 13 | 14 | /* 15 | * For easier debugging in development mode, you can import the following file 16 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 17 | * 18 | * This import should be commented out in production mode because it will have a negative impact 19 | * on performance if an error is thrown. 20 | */ 21 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 22 | -------------------------------------------------------------------------------- /workbox-inject.js: -------------------------------------------------------------------------------- 1 | const { injectManifest } = require("workbox-build"); 2 | 3 | let workboxConfig = { 4 | globDirectory: "dist/prog-web-news", 5 | globPatterns: ["favicon.ico", "index.html", "*.css", "*.js", "assets/**/*", "offline/**/*"], 6 | globIgnores: [ 7 | // Skip ES5 bundles for Angular 8 | "**/*-es5.*.js", 9 | // Config file for Azure Static Web Apps 10 | "assets/staticwebapps.config.json", 11 | ], 12 | 13 | swSrc: "src/service-worker.js", 14 | swDest: "dist/prog-web-news/sw.js", 15 | 16 | // Angular takes care of cache busting for JS and CSS (in prod mode) 17 | dontCacheBustURLsMatching: new RegExp(".+.[a-f0-9]{20}.(?:js|css)"), 18 | 19 | // By default, Workbox will not cache files larger than 2Mb (might be an issue for dev builds) 20 | maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, // 4Mb 21 | }; 22 | 23 | injectManifest(workboxConfig).then(({ count, size }) => { 24 | console.log( 25 | `Generated ${workboxConfig.swDest}, which will precache ${count} files, totaling ${size} bytes.` 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | browserName: 'chrome' 17 | }, 18 | directConnect: true, 19 | SELENIUM_PROMISE_MANAGER: false, 20 | baseUrl: 'http://localhost:4200/', 21 | framework: 'jasmine', 22 | jasmineNodeOpts: { 23 | showColors: true, 24 | defaultTimeoutInterval: 30000, 25 | print: function() {} 26 | }, 27 | onPrepare() { 28 | require('ts-node').register({ 29 | project: require('path').join(__dirname, './tsconfig.json') 30 | }); 31 | jasmine.getEnv().addReporter(new SpecReporter({ 32 | spec: { 33 | displayStacktrace: StacktraceOption.PRETTY 34 | } 35 | })); 36 | } 37 | }; -------------------------------------------------------------------------------- /src/app/posts/posts.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { GhostService } from '../ghost.service'; 3 | import { Title } from '@angular/platform-browser'; 4 | import { SettingsService } from '../settings.service'; 5 | 6 | @Component({ 7 | selector: 'app-posts', 8 | templateUrl: './posts.component.html', 9 | styleUrls: ['./posts.component.scss'], 10 | }) 11 | export class PostsComponent implements OnInit { 12 | posts; 13 | 14 | constructor( 15 | private ghostService: GhostService, 16 | private titleService: Title, 17 | private settingsService: SettingsService 18 | ) {} 19 | 20 | ngOnInit(): void { 21 | this.titleService.setTitle(this.settingsService.buildAppTitle('All posts')); 22 | 23 | this.ghostService.ghostConnection.posts 24 | .browse({ 25 | fields: 'slug,title,excerpt, custom_excerpt,published_at', 26 | include: 'authors, tags', 27 | }) 28 | .then((posts) => { 29 | this.posts = posts; 30 | }) 31 | .catch((err) => { 32 | console.error(err); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/home/home.component.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | {{ 11 | featuredPost.title 12 | }} 14 | 15 | {{ featuredPost.title }} 21 | 22 | 23 | {{ featuredPost.custom_excerpt }} 24 | 25 | 26 | 27 |
34 | {{ featuredPost.authors[0].name }} 35 | {{ featuredPost.published_at | date:'fullDate' }} 36 |
37 | 38 |
39 | 40 | 41 | -------------------------------------------------------------------------------- /src/sw-recipes.js: -------------------------------------------------------------------------------- 1 | import { pageCache, imageCache, staticResourceCache, googleFontsCache, offlineFallback } from 'workbox-recipes' 2 | import { precacheAndRoute } from 'workbox-precaching' 3 | 4 | // Include at least offline.html in the Workbox manifest (optionally, add full app shell) 5 | precacheAndRoute(self.__WB_MANIFEST) 6 | 7 | // Uses Network First strategy to serve all navigation requests 8 | pageCache() 9 | 10 | // Uses Stale-While-Revalidate / Cache First strategies for Google Fonts CSS / font files 11 | googleFontsCache() 12 | 13 | // Uses Stale-While-Revalidate strategy to serve CSS, JavaScript, and Web Worker requests 14 | staticResourceCache() 15 | 16 | // Uses Stale-While-Revalidate strategy to serve all image requests 17 | imageCache() 18 | 19 | // Serves a precached web page, image, or font if there's neither connection nor cache hit 20 | offlineFallback() 21 | 22 | // Next: 23 | // 1. Use injectManifest() from workbox-build module 24 | // 2. Build the resulting file with your favorite bundler 25 | // 3. Register service worker you received in your JS code (f. ex. using register() from workbox-window) 26 | // 4. Deploy and let your visitors enjoy ofline-ready, network optimized experience! -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | jasmineHtmlReporter: { 19 | suppressAll: true // removes the duplicated traces 20 | }, 21 | coverageReporter: { 22 | dir: require('path').join(__dirname, './coverage/prog-web-news'), 23 | subdir: '.', 24 | reporters: [ 25 | { type: 'html' }, 26 | { type: 'text-summary' } 27 | ] 28 | }, 29 | reporters: ['progress', 'kjhtml'], 30 | port: 9876, 31 | colors: true, 32 | logLevel: config.LOG_INFO, 33 | autoWatch: true, 34 | browsers: ['Chrome'], 35 | singleRun: false, 36 | restartOnFileChange: true 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automating a service worker with Workbox 6 - demo app 2 | 3 | **Did you came here from PWA Workshop? Follow [this repo](https://github.com/webmaxru/pwa-workshop-docs/) to get started.** 4 | 5 | Supporting resources: 6 | 7 | - ["Automating a service worker with Workbox 6" tech talk video](https://www.youtube.com/watch?v=iN-vzuVV_6E&list=PLmXhAjRjRcwKLhoDrGEeI-t67Wg6_0eD8&index=5) 8 | - Slides in [English](https://slides.com/webmax/workbox6-2022) and [Russian](https://slides.com/webmax/workbox-6-ru) 9 | - [Demo hosted on Azure Static Web Apps](https://black-beach-0a05a8c1e.azurestaticapps.net/) 10 | 11 | Testing offline: 12 | 13 | ![Demo](https://github.com/webmaxru/prog-web-news/raw/main/src/assets/img/wb6.gif) 14 | 15 | ## Application 16 | 17 | The application itself was created using Angular but everything related to Workbox library is framework-agnostic. 18 | 19 | * Source service worker https://github.com/webmaxru/prog-web-news/blob/main/src/service-worker.js 20 | * SW build script https://github.com/webmaxru/prog-web-news/blob/main/workbox-inject.js 21 | * SW bundling config https://github.com/webmaxru/prog-web-news/blob/main/rollup.config.js 22 | * App build command (order is important) https://github.com/webmaxru/prog-web-news/blob/main/package.json#L12 23 | 24 | -------------------------------------------------------------------------------- /src/app/post/post.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Router, ActivatedRoute, ParamMap } from '@angular/router'; 3 | import { GhostService } from '../ghost.service'; 4 | import { Title } from '@angular/platform-browser'; 5 | import { SettingsService } from '../settings.service'; 6 | 7 | @Component({ 8 | selector: 'app-post', 9 | templateUrl: './post.component.html', 10 | styleUrls: ['./post.component.scss'], 11 | }) 12 | export class PostComponent implements OnInit { 13 | postSlug: string; 14 | post; 15 | 16 | constructor( 17 | private route: ActivatedRoute, 18 | private router: Router, 19 | private ghostService: GhostService, 20 | private titleService: Title, 21 | private settingsService: SettingsService 22 | ) { 23 | this.postSlug = ''; 24 | } 25 | 26 | ngOnInit(): void { 27 | this.postSlug = this.route.snapshot.paramMap.get('slug'); 28 | 29 | this.ghostService.ghostConnection.posts 30 | .read({ slug: this.postSlug, include: 'authors, tags' }) 31 | .then((post) => { 32 | this.post = post; 33 | this.titleService.setTitle( 34 | this.settingsService.buildAppTitle(post.title) 35 | ); 36 | }) 37 | .catch((err) => { 38 | console.error(err); 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/assets/app.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Progressive Web News", 3 | "short_name": "Prog Web News", 4 | "theme_color": "#9c27b0", 5 | "background_color": "#fafafa", 6 | "display": "standalone", 7 | "orientation": "portrait", 8 | "scope": "/", 9 | "start_url": "/", 10 | "icons": [ 11 | { 12 | "src": "icons/icon-72x72.png", 13 | "sizes": "72x72", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "icons/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "icons/icon-128x128.png", 23 | "sizes": "128x128", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "icons/icon-144x144.png", 28 | "sizes": "144x144", 29 | "type": "image/png", 30 | "purpose": "any maskable" 31 | }, 32 | { 33 | "src": "icons/icon-152x152.png", 34 | "sizes": "152x152", 35 | "type": "image/png" 36 | }, 37 | { 38 | "src": "icons/icon-192x192.png", 39 | "sizes": "192x192", 40 | "type": "image/png" 41 | }, 42 | { 43 | "src": "icons/icon-384x384.png", 44 | "sizes": "384x384", 45 | "type": "image/png" 46 | }, 47 | { 48 | "src": "icons/icon-512x512.png", 49 | "sizes": "512x512", 50 | "type": "image/png" 51 | } 52 | ] 53 | } -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 16, 14, 12. 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local arm64/Apple Silicon. 10 | "args": { "VARIANT": "16" } 11 | }, 12 | 13 | // Set *default* container specific settings.json values on container create. 14 | "settings": {}, 15 | 16 | // Add the IDs of extensions you want installed when the container is created. 17 | "extensions": ["dbaeumer.vscode-eslint", "yandeu.five-server"], 18 | 19 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 20 | "forwardPorts": [5000], 21 | 22 | // Use 'postCreateCommand' to run commands after the container is created. 23 | "postCreateCommand": "npm install", 24 | 25 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 26 | "remoteUser": "node", 27 | "features": { 28 | "docker-in-docker": "20.10", 29 | "docker-from-docker": "20.10", 30 | "git": "2.33.1", 31 | "github-cli": "2.2.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { HomeComponent } from './home/home.component'; 4 | import { PostsComponent } from './posts/posts.component'; 5 | import { PostComponent } from './post/post.component'; 6 | import { AboutComponent } from './about/about.component'; 7 | import { NonCachedRouteComponent } from './non-cached-route/non-cached-route.component'; 8 | import { CachedRouteComponent } from './cached-route/cached-route.component'; 9 | import { PostTweetComponent } from './post-tweet/post-tweet.component'; 10 | import { PushSubscriptionComponent } from './push-subscription/push-subscription.component'; 11 | 12 | const routes: Routes = [ 13 | { path: '', component: HomeComponent }, 14 | { path: 'posts', component: PostsComponent }, 15 | { path: 'posts/:slug', component: PostComponent }, 16 | { path: 'about', component: AboutComponent }, 17 | { 18 | path: 'cached-route', 19 | component: CachedRouteComponent, 20 | }, 21 | { 22 | path: 'non-cached-route', 23 | component: NonCachedRouteComponent, 24 | }, 25 | { 26 | path: 'feedback', 27 | component: PostTweetComponent, 28 | }, 29 | { 30 | path: 'subscription', 31 | component: PushSubscriptionComponent, 32 | }, 33 | ]; 34 | 35 | @NgModule({ 36 | imports: [RouterModule.forRoot(routes)], 37 | exports: [RouterModule], 38 | }) 39 | export class AppRoutingModule {} 40 | -------------------------------------------------------------------------------- /src/app/app-shell/app-shell.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { LayoutModule } from '@angular/cdk/layout'; 2 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatIconModule } from '@angular/material/icon'; 6 | import { MatListModule } from '@angular/material/list'; 7 | import { MatSidenavModule } from '@angular/material/sidenav'; 8 | import { MatToolbarModule } from '@angular/material/toolbar'; 9 | 10 | import { AppShellComponent } from './app-shell.component'; 11 | 12 | describe('AppShellComponent', () => { 13 | let component: AppShellComponent; 14 | let fixture: ComponentFixture; 15 | 16 | beforeEach(waitForAsync(() => { 17 | TestBed.configureTestingModule({ 18 | declarations: [AppShellComponent], 19 | imports: [ 20 | NoopAnimationsModule, 21 | LayoutModule, 22 | MatButtonModule, 23 | MatIconModule, 24 | MatListModule, 25 | MatSidenavModule, 26 | MatToolbarModule, 27 | ] 28 | }).compileComponents(); 29 | })); 30 | 31 | beforeEach(() => { 32 | fixture = TestBed.createComponent(AppShellComponent); 33 | component = fixture.componentInstance; 34 | fixture.detectChanges(); 35 | }); 36 | 37 | it('should compile', () => { 38 | expect(component).toBeTruthy(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { GhostService } from '../ghost.service'; 3 | import { Title } from '@angular/platform-browser'; 4 | import { SettingsService } from '../settings.service'; 5 | 6 | @Component({ 7 | selector: 'app-home', 8 | templateUrl: './home.component.html', 9 | styleUrls: ['./home.component.scss'], 10 | providers: [GhostService], 11 | }) 12 | export class HomeComponent implements OnInit { 13 | latestPosts; 14 | featuredPost; 15 | 16 | constructor(private ghostService: GhostService, private titleService: Title, private settingsService: SettingsService) {} 17 | 18 | ngOnInit(): void { 19 | this.ghostService.ghostConnection.posts 20 | .browse({ 21 | limit: 1, 22 | fields: 'slug,title,custom_excerpt,published_at,feature_image', 23 | include: 'authors, tags', 24 | }) 25 | .then((posts) => { 26 | this.featuredPost = posts[0]; 27 | this.titleService.setTitle(this.settingsService.buildAppTitle(posts[0].title)); 28 | }) 29 | .catch((err: any) => { 30 | console.error(err); 31 | }); 32 | 33 | this.ghostService.ghostConnection.posts 34 | .browse({ 35 | limit: 4, 36 | fields: 'slug,title,published_at', 37 | include: 'authors, tags', 38 | }) 39 | .then((posts) => { 40 | this.latestPosts = [...posts.slice(1)]; 41 | }) 42 | .catch((err) => { 43 | console.error(err); 44 | }); 45 | 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prog-web-news", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "npm run build-pwa", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e", 11 | "build-sw": "node workbox-inject.js && npx rollup -c", 12 | "build-pwa": "ng build --prod && npm run build-sw" 13 | }, 14 | "private": true, 15 | "dependencies": { 16 | "@angular/animations": "~13.0.0", 17 | "@angular/cdk": "^13.0.0", 18 | "@angular/common": "~13.0.0", 19 | "@angular/compiler": "~13.0.0", 20 | "@angular/core": "~13.0.0", 21 | "@angular/forms": "~13.0.0", 22 | "@angular/material": "^13.0.0", 23 | "@angular/platform-browser": "~13.0.0", 24 | "@angular/platform-browser-dynamic": "~13.0.0", 25 | "@angular/router": "~13.0.0", 26 | "rxjs": "~7.4.0", 27 | "tslib": "^2.3.0", 28 | "zone.js": "~0.11.4", 29 | "@tryghost/content-api": "^1.4.10", 30 | "workbox-build": "^6.1.2" 31 | }, 32 | "devDependencies": { 33 | "@angular-devkit/build-angular": "~13.0.1", 34 | "@angular/cli": "~13.0.1", 35 | "@angular/compiler-cli": "~13.0.0", 36 | "@types/jasmine": "~3.10.0", 37 | "@types/node": "^12.11.1", 38 | "jasmine-core": "~3.10.0", 39 | "karma": "~6.3.0", 40 | "karma-chrome-launcher": "~3.1.0", 41 | "karma-coverage": "~2.0.3", 42 | "karma-jasmine": "~4.0.0", 43 | "karma-jasmine-html-reporter": "~1.7.0", 44 | "typescript": "~4.4.3", 45 | "rollup": "^2.34.0", 46 | "rollup-plugin-node-resolve": "^5.2.0", 47 | "rollup-plugin-replace": "^2.2.0", 48 | "rollup-plugin-terser": "^7.0.2", 49 | "serve": "^11.3.2" 50 | } 51 | } -------------------------------------------------------------------------------- /src/app/push-subscription.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { Observable } from 'rxjs'; 4 | 5 | import { ConfigService } from './config.service'; 6 | import { ApiService } from './api.service'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class PushSubscriptionService { 12 | private pushSubscriptionUrl: string; 13 | 14 | constructor( 15 | private apiService: ApiService, 16 | private configService: ConfigService 17 | ) { 18 | this.pushSubscriptionUrl = `${this.configService.get( 19 | 'PUSH_API_URL' 20 | )}/webpush`; 21 | } 22 | 23 | addSubscriber(subscription): Observable { 24 | console.log('[Push Subscription Service] Adding subscriber'); 25 | 26 | let body = { 27 | action: 'subscribe', 28 | subscription: subscription 29 | }; 30 | 31 | return this.apiService.callApi(this.pushSubscriptionUrl, 'POST', body); 32 | } 33 | 34 | deleteSubscriber(subscription): Observable { 35 | console.log('[Push Subscription Service] Deleting subscriber'); 36 | 37 | let body = { 38 | action: 'unsubscribe', 39 | subscription: subscription 40 | }; 41 | 42 | return this.apiService.callApi(this.pushSubscriptionUrl, 'POST', body); 43 | } 44 | 45 | urlBase64ToUint8Array(base64String) { 46 | const padding = '='.repeat((4 - base64String.length % 4) % 4); 47 | const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/'); 48 | const rawData = window.atob(base64); 49 | const outputArray = new Uint8Array(rawData.length); 50 | for (let i = 0; i < rawData.length; ++i) { 51 | outputArray[i] = rawData.charCodeAt(i); 52 | } 53 | return outputArray; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/app/post-tweet/post-tweet.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Subscription } from 'rxjs'; 3 | 4 | import { ConfigService } from '../config.service'; 5 | import { ApiService } from '../api.service'; 6 | 7 | import { Observable } from 'rxjs'; 8 | import { MatSnackBar } from '@angular/material/snack-bar'; 9 | 10 | @Component({ 11 | selector: 'post-tweet', 12 | templateUrl: './post-tweet.component.html', 13 | styleUrls: ['./post-tweet.component.css'] 14 | }) 15 | export class PostTweetComponent implements OnInit { 16 | 17 | message; 18 | private snackBarDuration: number = 2000; 19 | subscription: Subscription; 20 | dataApiUrl; 21 | 22 | constructor( 23 | private apiService: ApiService, 24 | private configService: ConfigService, 25 | public snackBar: MatSnackBar 26 | ) { 27 | this.dataApiUrl = this.configService.get('DATA_API_URL'); 28 | } 29 | 30 | 31 | ngOnInit() { 32 | } 33 | 34 | messageFormSubmit(messageForm: any) { 35 | 36 | if (messageForm.valid) { 37 | 38 | this.subscription = this.postTweet(messageForm.value.message).subscribe( 39 | res => { 40 | console.log('[App] Feedback was posted', res) 41 | let snackBarRef = this.snackBar.open('Feedback was sent', null, { 42 | duration: this.snackBarDuration 43 | }); 44 | }, 45 | err => { 46 | let snackBarRef = this.snackBar.open('Feedback will be sent after you go online', null, { 47 | duration: this.snackBarDuration 48 | }); 49 | }); 50 | 51 | messageForm.reset() 52 | 53 | } 54 | 55 | } 56 | 57 | postTweet(message: string): Observable { 58 | console.log('[Feedback Service] Sending feedback'); 59 | 60 | return this.apiService.callApi(`${this.dataApiUrl}/post-tweet`, 'POST', { 61 | message 62 | }); 63 | } 64 | 65 | ngOnDestroy() { 66 | this.subscription?.unsubscribe(); 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/azure-static-web-apps-black-beach-0a05a8c1e.yml: -------------------------------------------------------------------------------- 1 | name: Azure Static Web Apps CI/CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened, closed] 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build_and_deploy_job: 14 | if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') 15 | runs-on: ubuntu-latest 16 | name: Build and Deploy Job 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: 20 | submodules: true 21 | - name: Build And Deploy 22 | id: builddeploy 23 | uses: Azure/static-web-apps-deploy@v0.0.1-preview 24 | with: 25 | azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_BLACK_BEACH_0A05A8C1E }} 26 | repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) 27 | action: "upload" 28 | ###### Repository/Build Configurations - These values can be configured to match you app requirements. ###### 29 | # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig 30 | app_location: "/" # App source code path 31 | api_location: "api" # Api source code path - optional 32 | output_location: "dist/prog-web-news" # Built app content directory - optional 33 | ###### End of Repository/Build Configurations ###### 34 | 35 | close_pull_request_job: 36 | if: github.event_name == 'pull_request' && github.event.action == 'closed' 37 | runs-on: ubuntu-latest 38 | name: Close Pull Request Job 39 | steps: 40 | - name: Close Pull Request 41 | id: closepullrequest 42 | uses: Azure/static-web-apps-deploy@v0.0.1-preview 43 | with: 44 | azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_BLACK_BEACH_0A05A8C1E }} 45 | action: "close" 46 | -------------------------------------------------------------------------------- /src/app/api.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient, HttpHeaders } from '@angular/common/http'; 3 | 4 | import { Observable, throwError } from 'rxjs'; 5 | import { catchError } from 'rxjs/operators'; 6 | 7 | import { MatSnackBar } from '@angular/material/snack-bar'; 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class ApiService { 13 | private snackBarDuration: number = 2000; 14 | 15 | constructor(private httpClient: HttpClient, public snackBar: MatSnackBar) {} 16 | 17 | callApi( 18 | url: string, 19 | method: string = 'GET', 20 | body: any = {}, 21 | headers: any = new HttpHeaders() 22 | ): Observable { 23 | this.log('callApi: ' + url); 24 | 25 | switch (method) { 26 | case 'GET': { 27 | return this.httpClient 28 | .get(url, { headers }) 29 | .pipe(catchError(this.handleError(url))); 30 | } 31 | case 'POST': { 32 | return this.httpClient 33 | .post(url, body, { headers }) 34 | .pipe(catchError(this.handleError(url))); 35 | } 36 | case 'PUT': { 37 | return this.httpClient 38 | .put(url, body, { headers }) 39 | .pipe(catchError(this.handleError(url))); 40 | } 41 | case 'DELETE': { 42 | return this.httpClient 43 | .delete(url, { headers }) 44 | .pipe(catchError(this.handleError(url))); 45 | } 46 | default: { 47 | this.log('Method not implemented'); 48 | return null; 49 | } 50 | } 51 | } 52 | 53 | private handleError(url = null, result?: T) { 54 | return (error: any): Observable => { 55 | 56 | let errorMessage = error.message || error 57 | this.log(`${url} failed: ${errorMessage}`); 58 | 59 | let snackBarRef = this.snackBar.open(errorMessage, null, { 60 | duration: this.snackBarDuration 61 | }); 62 | 63 | return throwError(errorMessage); 64 | }; 65 | } 66 | 67 | private log(message: string) { 68 | console.log('[Api Service]: ' + message); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/app-shell/app-shell.component.scss: -------------------------------------------------------------------------------- 1 | .sidenav-container { 2 | height: 100%; 3 | min-height: 100vh; 4 | } 5 | 6 | .sidenav { 7 | width: 200px; 8 | } 9 | 10 | .sidenav .mat-toolbar { 11 | background: inherit; 12 | } 13 | 14 | .mat-toolbar.mat-primary { 15 | position: sticky; 16 | top: 0; 17 | z-index: 1; 18 | } 19 | 20 | .page-wrap { 21 | display: flex; 22 | flex-direction: column; 23 | min-height: 100vh; 24 | } 25 | 26 | .content { 27 | flex: 1; 28 | } 29 | 30 | 31 | /* 32 | * Make the Component injected by Router Outlet full height: 33 | */ 34 | main { 35 | display: flex; 36 | flex-direction: column; 37 | > *:not(router-outlet) { 38 | flex: 1; 39 | display: block; 40 | } 41 | } 42 | 43 | .footer { 44 | padding: 12px; 45 | font-size: 12px; 46 | background-color: #ccc; 47 | } 48 | 49 | .footer-list { 50 | align-items: center; 51 | display: flex; 52 | flex-flow: row wrap; 53 | padding: 8px; 54 | } 55 | 56 | .footer-logo { 57 | flex: 1; 58 | } 59 | 60 | .footer-logo span { 61 | display: inline-block; 62 | line-height: 30px; 63 | margin: 0 0 0 20px; 64 | vertical-align: bottom; 65 | 66 | a { 67 | font-size: 16px; 68 | padding: 0; 69 | } 70 | } 71 | 72 | .logo { 73 | height: 30px; 74 | } 75 | 76 | .footer-links { 77 | display: flex; 78 | justify-content: center; 79 | align-items: center; 80 | flex: 1; 81 | 82 | .links { 83 | margin: 0 20px; 84 | } 85 | } 86 | 87 | .footer-copyright { 88 | display: flex; 89 | flex: 1; 90 | justify-content: flex-end; 91 | flex-direction: column; 92 | min-width: 100px; 93 | margin-top: 16px; 94 | 95 | > div { 96 | display: flex; 97 | flex-direction: column; 98 | align-self: flex-end; 99 | text-align: center; 100 | } 101 | 102 | @media (min-width: 885px) { 103 | margin-top: 0; 104 | } 105 | } 106 | 107 | a { 108 | &:hover, 109 | &:focus { 110 | text-decoration: underline; 111 | } 112 | } 113 | 114 | @media screen and (max-width: 884px){ 115 | .footer-list { 116 | flex-direction: column; 117 | } 118 | } -------------------------------------------------------------------------------- /src/assets/staticwebapps.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "routes": [ 3 | { 4 | "route": "/profile", 5 | "allowedRoles": ["authenticated"] 6 | }, 7 | { 8 | "route": "/admin/*", 9 | "allowedRoles": ["administrator"] 10 | }, 11 | { 12 | "route": "/images/*", 13 | "headers": { 14 | "cache-control": "must-revalidate, max-age=15770000" 15 | } 16 | }, 17 | { 18 | "route": "/api/*", 19 | "methods": [ "GET" ], 20 | "allowedRoles": ["registeredusers"] 21 | }, 22 | { 23 | "route": "/api/*", 24 | "methods": [ "PUT", "POST", "PATCH", "DELETE" ], 25 | "allowedRoles": ["administrator"] 26 | }, 27 | { 28 | "route": "/api/*", 29 | "allowedRoles": ["authenticated"] 30 | }, 31 | { 32 | "route": "/customers/contoso", 33 | "allowedRoles": ["administrator", "customers_contoso"] 34 | }, 35 | { 36 | "route": "/login", 37 | "rewrite": "/.auth/login/github" 38 | }, 39 | { 40 | "route": "/.auth/login/twitter", 41 | "statusCode": 404 42 | }, 43 | { 44 | "route": "/logout", 45 | "redirect": "/.auth/logout" 46 | }, 47 | { 48 | "route": "/calendar/*", 49 | "rewrite": "/calendar.html" 50 | }, 51 | { 52 | "route": "/specials", 53 | "redirect": "/deals", 54 | "statusCode": 301 55 | } 56 | ], 57 | "navigationFallback": { 58 | "rewrite": "index.html", 59 | "exclude": ["/assets/*"] 60 | }, 61 | "responseOverrides": { 62 | "400" : { 63 | "rewrite": "/invalid-invitation-error.html" 64 | }, 65 | "401": { 66 | "redirect": "/login", 67 | "statusCode": 302 68 | }, 69 | "403": { 70 | "rewrite": "/custom-forbidden-page.html" 71 | }, 72 | "404": { 73 | "rewrite": "/404.html" 74 | } 75 | }, 76 | "globalHeaders": { 77 | "content-security-policy": "default-src https: 'unsafe-eval' 'unsafe-inline'; object-src 'none'" 78 | }, 79 | "mimeTypes": { 80 | ".json": "text/json" 81 | } 82 | } -------------------------------------------------------------------------------- /src/app/app-shell/app-shell.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; 3 | import { Observable } from 'rxjs'; 4 | import { map, shareReplay } from 'rxjs/operators'; 5 | import { SettingsService } from '../settings.service'; 6 | import { Workbox, messageSW } from 'workbox-window'; 7 | import { MatSnackBar } from '@angular/material/snack-bar'; 8 | 9 | @Component({ 10 | selector: 'app-shell', 11 | templateUrl: './app-shell.component.html', 12 | styleUrls: ['./app-shell.component.scss'], 13 | }) 14 | export class AppShellComponent { 15 | title: string; 16 | 17 | isHandset$: Observable = this.breakpointObserver 18 | .observe(Breakpoints.Handset) 19 | .pipe( 20 | map((result) => result.matches), 21 | shareReplay() 22 | ); 23 | 24 | constructor( 25 | private breakpointObserver: BreakpointObserver, 26 | private settingsService: SettingsService, 27 | private snackBar: MatSnackBar 28 | ) { 29 | this.title = settingsService.appTitle; 30 | 31 | if ('serviceWorker' in navigator) { 32 | const wb = new Workbox('/sw.js'); 33 | 34 | const showSkipWaitingPrompt = (event) => { 35 | let snackBarRef = this.snackBar.open( 36 | 'A new version of the website available', 37 | 'Reload page', 38 | { 39 | duration: 5000, 40 | } 41 | ); 42 | 43 | // Displaying prompt 44 | 45 | snackBarRef.onAction().subscribe(() => { 46 | // Assuming the user accepted the update, set up a listener 47 | // that will reload the page as soon as the previously waiting 48 | // service worker has taken control. 49 | wb.addEventListener('controlling', () => { 50 | window.location.reload(); 51 | }); 52 | 53 | // This will postMessage() to the waiting service worker. 54 | wb.messageSkipWaiting(); 55 | }); 56 | }; 57 | 58 | // Add an event listener to detect when the registered 59 | // service worker has installed but is waiting to activate. 60 | wb.addEventListener('waiting', showSkipWaitingPrompt); 61 | 62 | wb.register() 63 | .then((reg) => { 64 | console.log('Successful service worker registration', reg); 65 | }) 66 | .catch((err) => 67 | console.error('Service worker registration failed', err) 68 | ); 69 | 70 | } else { 71 | console.error('Service Worker API is not supported in current browser'); 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule, Title } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { HttpClientModule } from '@angular/common/http'; 5 | 6 | import { AppRoutingModule } from './app-routing.module'; 7 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 8 | import { AppShellComponent } from './app-shell/app-shell.component'; 9 | import { LayoutModule } from '@angular/cdk/layout'; 10 | import { MatToolbarModule } from '@angular/material/toolbar'; 11 | import { MatButtonModule } from '@angular/material/button'; 12 | import { MatSidenavModule } from '@angular/material/sidenav'; 13 | import { MatIconModule } from '@angular/material/icon'; 14 | import { MatListModule } from '@angular/material/list'; 15 | import { MatCardModule } from '@angular/material/card'; 16 | import { MatProgressBarModule } from '@angular/material/progress-bar'; 17 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 18 | import { MatInputModule } from '@angular/material/input'; 19 | import { MatFormFieldModule } from '@angular/material/form-field'; 20 | 21 | import { HomeComponent } from './home/home.component'; 22 | import { PostsComponent } from './posts/posts.component'; 23 | import { PostComponent } from './post/post.component'; 24 | import { PostListComponent } from './shared/post-list/post-list.component'; 25 | import { PostCardComponent } from './shared/post-card/post-card.component'; 26 | import { AboutComponent } from './about/about.component'; 27 | 28 | import { CachedRouteComponent } from './cached-route/cached-route.component'; 29 | import { NonCachedRouteComponent } from './non-cached-route/non-cached-route.component'; 30 | import { PostTweetComponent } from './post-tweet/post-tweet.component'; 31 | import { PushSubscriptionComponent } from './push-subscription/push-subscription.component'; 32 | 33 | 34 | @NgModule({ 35 | declarations: [ 36 | AppShellComponent, 37 | HomeComponent, 38 | PostsComponent, 39 | PostComponent, 40 | PostListComponent, 41 | PostCardComponent, 42 | AboutComponent, 43 | CachedRouteComponent, 44 | NonCachedRouteComponent, 45 | PostTweetComponent, 46 | PushSubscriptionComponent, 47 | ], 48 | imports: [ 49 | BrowserModule, 50 | AppRoutingModule, 51 | BrowserAnimationsModule, 52 | LayoutModule, 53 | MatToolbarModule, 54 | MatButtonModule, 55 | MatSidenavModule, 56 | MatIconModule, 57 | MatListModule, 58 | MatCardModule, 59 | MatProgressBarModule, 60 | MatSnackBarModule, 61 | MatInputModule, 62 | MatFormFieldModule, 63 | FormsModule, 64 | HttpClientModule, 65 | ], 66 | providers: [ 67 | Title 68 | ], 69 | bootstrap: [AppShellComponent], 70 | }) 71 | export class AppModule {} 72 | -------------------------------------------------------------------------------- /src/app/app-shell/app-shell.component.html: -------------------------------------------------------------------------------- 1 | 2 | 10 | Menu 11 | 12 | Home 13 | All posts 14 | About 15 | Feedback 16 | Subscription 17 | 18 | 19 | 20 |
21 |
22 | 23 | 32 | {{ title }} 33 | 34 |
35 |
36 | 37 |
38 | 39 | 82 |
83 |
84 |
85 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js'; // Included with Angular CLI. 59 | 60 | 61 | /*************************************************************************************************** 62 | * APPLICATION IMPORTS 63 | */ 64 | -------------------------------------------------------------------------------- /src/app/push-subscription/push-subscription.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { MatSnackBar } from '@angular/material/snack-bar'; 3 | 4 | import { ConfigService } from './../config.service'; 5 | import { PushSubscriptionService } from './../push-subscription.service'; 6 | 7 | // Import SwPush here 8 | 9 | @Component({ 10 | selector: 'push-subscription', 11 | templateUrl: './push-subscription.component.html', 12 | styleUrls: ['./push-subscription.component.css'] 13 | }) 14 | export class PushSubscriptionComponent implements OnInit { 15 | private VAPID_PUBLIC_KEY: string; 16 | private snackBarDuration: number = 2000 17 | private swScope: string = './'; 18 | 19 | constructor(private pushSubscriptionService: PushSubscriptionService, public snackBar: MatSnackBar, private configService: ConfigService) { 20 | } 21 | 22 | ngOnInit() { 23 | this.VAPID_PUBLIC_KEY = this.configService.get('VAPID_PUBLIC_KEY'); 24 | } 25 | 26 | subscribeToPush() { 27 | 28 | let convertedVapidKey = this.pushSubscriptionService.urlBase64ToUint8Array(this.VAPID_PUBLIC_KEY); 29 | 30 | navigator['serviceWorker'] 31 | .getRegistration(this.swScope) 32 | .then(registration => { 33 | 34 | registration.pushManager 35 | .subscribe({ userVisibleOnly: true, applicationServerKey: convertedVapidKey }) 36 | .then(pushSubscription => { 37 | 38 | this.pushSubscriptionService.addSubscriber(pushSubscription) 39 | .subscribe( 40 | 41 | res => { 42 | console.log('[App] Add subscriber request answer', res) 43 | 44 | let snackBarRef = this.snackBar.open('Now you are subscribed', null, { 45 | duration: this.snackBarDuration 46 | }); 47 | }, 48 | err => { 49 | console.error('[App] Add subscriber request failed', err) 50 | } 51 | 52 | ) 53 | 54 | }); 55 | 56 | }) 57 | .catch(err => { 58 | console.error(err); 59 | }) 60 | 61 | 62 | } 63 | 64 | unsubscribeFromPush() { 65 | 66 | navigator['serviceWorker'] 67 | .getRegistration(this.swScope) 68 | .then(registration => { 69 | 70 | registration.pushManager 71 | .getSubscription() 72 | .then(pushSubscription => { 73 | 74 | this.pushSubscriptionService.deleteSubscriber(pushSubscription) 75 | .subscribe( 76 | 77 | res => { 78 | console.log('[App] Delete subscriber request answer', res) 79 | 80 | let snackBarRef = this.snackBar.open('Now you are unsubscribed', null, { 81 | duration: this.snackBarDuration 82 | }); 83 | 84 | // Unsubscribe current client (browser) 85 | 86 | pushSubscription.unsubscribe() 87 | .then(success => { 88 | console.log('[App] Unsubscription successful', success) 89 | }) 90 | .catch(err => { 91 | console.log('[App] Unsubscription failed', err) 92 | }) 93 | 94 | }, 95 | err => { 96 | console.error('[App] Delete subscription request failed', err) 97 | } 98 | 99 | ) 100 | }) 101 | 102 | }) 103 | .catch(err => { 104 | console.error(err); 105 | }) 106 | 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": [ 4 | "codelyzer" 5 | ], 6 | "rules": { 7 | "align": { 8 | "options": [ 9 | "parameters", 10 | "statements" 11 | ] 12 | }, 13 | "array-type": false, 14 | "arrow-return-shorthand": true, 15 | "curly": true, 16 | "deprecation": { 17 | "severity": "warning" 18 | }, 19 | "eofline": true, 20 | "import-blacklist": [ 21 | true, 22 | "rxjs/Rx" 23 | ], 24 | "import-spacing": true, 25 | "indent": { 26 | "options": [ 27 | "spaces" 28 | ] 29 | }, 30 | "max-classes-per-file": false, 31 | "max-line-length": [ 32 | true, 33 | 140 34 | ], 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-console": [ 47 | true, 48 | "debug", 49 | "info", 50 | "time", 51 | "timeEnd", 52 | "trace" 53 | ], 54 | "no-empty": false, 55 | "no-inferrable-types": [ 56 | true, 57 | "ignore-params" 58 | ], 59 | "no-non-null-assertion": true, 60 | "no-redundant-jsdoc": true, 61 | "no-switch-case-fall-through": true, 62 | "no-var-requires": false, 63 | "object-literal-key-quotes": [ 64 | true, 65 | "as-needed" 66 | ], 67 | "quotemark": [ 68 | true, 69 | "single" 70 | ], 71 | "semicolon": { 72 | "options": [ 73 | "always" 74 | ] 75 | }, 76 | "space-before-function-paren": { 77 | "options": { 78 | "anonymous": "never", 79 | "asyncArrow": "always", 80 | "constructor": "never", 81 | "method": "never", 82 | "named": "never" 83 | } 84 | }, 85 | "typedef": [ 86 | true, 87 | "call-signature" 88 | ], 89 | "typedef-whitespace": { 90 | "options": [ 91 | { 92 | "call-signature": "nospace", 93 | "index-signature": "nospace", 94 | "parameter": "nospace", 95 | "property-declaration": "nospace", 96 | "variable-declaration": "nospace" 97 | }, 98 | { 99 | "call-signature": "onespace", 100 | "index-signature": "onespace", 101 | "parameter": "onespace", 102 | "property-declaration": "onespace", 103 | "variable-declaration": "onespace" 104 | } 105 | ] 106 | }, 107 | "variable-name": { 108 | "options": [ 109 | "ban-keywords", 110 | "check-format", 111 | "allow-pascal-case" 112 | ] 113 | }, 114 | "whitespace": { 115 | "options": [ 116 | "check-branch", 117 | "check-decl", 118 | "check-operator", 119 | "check-separator", 120 | "check-type", 121 | "check-typecast" 122 | ] 123 | }, 124 | "component-class-suffix": true, 125 | "contextual-lifecycle": true, 126 | "directive-class-suffix": true, 127 | "no-conflicting-lifecycle": true, 128 | "no-host-metadata-property": true, 129 | "no-input-rename": true, 130 | "no-inputs-metadata-property": true, 131 | "no-output-native": true, 132 | "no-output-on-prefix": true, 133 | "no-output-rename": true, 134 | "no-outputs-metadata-property": true, 135 | "template-banana-in-box": true, 136 | "template-no-negated-async": true, 137 | "use-lifecycle-interface": true, 138 | "use-pipe-transform-interface": true, 139 | "directive-selector": [ 140 | true, 141 | "attribute", 142 | "app", 143 | "camelCase" 144 | ], 145 | "component-selector": [ 146 | true, 147 | "element", 148 | "app", 149 | "kebab-case" 150 | ] 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "cli": { 5 | "cache": { 6 | "enabled": true, 7 | "path": ".angular/cache", 8 | "environment": "all" 9 | } 10 | }, 11 | "newProjectRoot": "projects", 12 | "projects": { 13 | "prog-web-news": { 14 | "projectType": "application", 15 | "schematics": { 16 | "@schematics/angular:component": { 17 | "style": "scss" 18 | }, 19 | "@schematics/angular:application": { 20 | "strict": true 21 | } 22 | }, 23 | "root": "", 24 | "sourceRoot": "src", 25 | "prefix": "app", 26 | "architect": { 27 | "build": { 28 | "builder": "@angular-devkit/build-angular:browser", 29 | "options": { 30 | "outputPath": "dist/prog-web-news", 31 | "index": "src/index.html", 32 | "main": "src/main.ts", 33 | "polyfills": "src/polyfills.ts", 34 | "tsConfig": "tsconfig.app.json", 35 | "assets": [ 36 | "src/favicon.ico", 37 | "src/assets", 38 | "src/out-of-spa", 39 | "src/404error.html", 40 | "src/404.html", 41 | "src/offline" 42 | ], 43 | "styles": [ 44 | "./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css", 45 | "src/styles.scss" 46 | ], 47 | "scripts": [] 48 | }, 49 | "configurations": { 50 | "production": { 51 | "budgets": [ 52 | { 53 | "type": "initial", 54 | "maximumWarning": "500kb", 55 | "maximumError": "1mb" 56 | }, 57 | { 58 | "type": "anyComponentStyle", 59 | "maximumWarning": "2kb", 60 | "maximumError": "4kb" 61 | } 62 | ], 63 | "fileReplacements": [ 64 | { 65 | "replace": "src/environments/environment.ts", 66 | "with": "src/environments/environment.prod.ts" 67 | } 68 | ], 69 | "outputHashing": "all" 70 | }, 71 | "development": { 72 | "buildOptimizer": false, 73 | "optimization": false, 74 | "vendorChunk": true, 75 | "extractLicenses": false, 76 | "sourceMap": true, 77 | "namedChunks": true 78 | } 79 | }, 80 | "defaultConfiguration": "production" 81 | }, 82 | "serve": { 83 | "builder": "@angular-devkit/build-angular:dev-server", 84 | "options": { 85 | "browserTarget": "prog-web-news:build" 86 | }, 87 | "configurations": { 88 | "production": { 89 | "browserTarget": "prog-web-news:build:production" 90 | } 91 | } 92 | }, 93 | "extract-i18n": { 94 | "builder": "@angular-devkit/build-angular:extract-i18n", 95 | "options": { 96 | "browserTarget": "prog-web-news:build" 97 | } 98 | }, 99 | "test": { 100 | "builder": "@angular-devkit/build-angular:karma", 101 | "options": { 102 | "main": "src/test.ts", 103 | "polyfills": "src/polyfills.ts", 104 | "tsConfig": "tsconfig.spec.json", 105 | "karmaConfig": "karma.conf.js", 106 | "assets": [ 107 | "src/favicon.ico", 108 | "src/assets" 109 | ], 110 | "styles": [ 111 | "./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css", 112 | "src/styles.scss" 113 | ], 114 | "scripts": [] 115 | } 116 | }, 117 | "lint": { 118 | "builder": "@angular-devkit/build-angular:tslint", 119 | "options": { 120 | "tsConfig": [ 121 | "tsconfig.app.json", 122 | "tsconfig.spec.json", 123 | "e2e/tsconfig.json" 124 | ], 125 | "exclude": [ 126 | "**/node_modules/**" 127 | ] 128 | } 129 | }, 130 | "e2e": { 131 | "builder": "@angular-devkit/build-angular:protractor", 132 | "options": { 133 | "protractorConfig": "e2e/protractor.conf.js", 134 | "devServerTarget": "prog-web-news:serve" 135 | }, 136 | "configurations": { 137 | "production": { 138 | "devServerTarget": "prog-web-news:serve:production" 139 | } 140 | } 141 | } 142 | } 143 | } 144 | }, 145 | "defaultProject": "prog-web-news" 146 | } 147 | -------------------------------------------------------------------------------- /src/service-worker.js: -------------------------------------------------------------------------------- 1 | import { precacheAndRoute, createHandlerBoundToURL } from "workbox-precaching"; 2 | import { NavigationRoute, registerRoute } from "workbox-routing"; 3 | import { setCacheNameDetails, clientsClaim } from "workbox-core"; 4 | import { 5 | CacheFirst, 6 | NetworkFirst, 7 | StaleWhileRevalidate, 8 | } from "workbox-strategies"; 9 | import { ExpirationPlugin } from "workbox-expiration"; 10 | import { CacheableResponsePlugin } from "workbox-cacheable-response"; 11 | import { googleFontsCache, imageCache } from "workbox-recipes"; 12 | import { BackgroundSyncPlugin } from "workbox-background-sync"; 13 | import { BroadcastUpdatePlugin } from "workbox-broadcast-update"; 14 | 15 | // SETTINGS 16 | 17 | // Claiming control to start runtime caching asap 18 | clientsClaim(); 19 | 20 | // Use to update the app after user triggered refresh 21 | //self.skipWaiting(); 22 | 23 | // Setting custom cache names 24 | setCacheNameDetails({ precache: "wb6-precache", runtime: "wb6-runtime" }); 25 | 26 | // PRECACHING 27 | 28 | // Precache and serve resources from __WB_MANIFEST array 29 | precacheAndRoute(self.__WB_MANIFEST); 30 | 31 | // NAVIGATION ROUTING 32 | 33 | // This assumes /index.html has been precached. 34 | const navHandler = createHandlerBoundToURL("/index.html"); 35 | const navigationRoute = new NavigationRoute(navHandler, { 36 | denylist: [new RegExp("/out-of-spa/")], // Also might be specified explicitly via allowlist 37 | }); 38 | registerRoute(navigationRoute); 39 | 40 | // RUNTIME CACHING 41 | 42 | // Load details immediately and check for update right after 43 | registerRoute( 44 | new RegExp("https://progwebnews-app.azurewebsites.net.*content/posts/slug.*"), 45 | new StaleWhileRevalidate({ 46 | cacheName: "wb6-post", 47 | plugins: [ 48 | new ExpirationPlugin({ 49 | // Only cache requests for a week 50 | maxAgeSeconds: 7 * 24 * 60 * 60, 51 | }), 52 | new BroadcastUpdatePlugin(), 53 | ], 54 | }) 55 | ); 56 | 57 | // Keeping lists always fresh 58 | registerRoute( 59 | new RegExp("https://progwebnews-app.azurewebsites.net.*content/posts.*"), 60 | new NetworkFirst() 61 | ); 62 | 63 | // Avatars can live in cache 64 | registerRoute( 65 | ({ url }) => url.hostname.includes("gravatar.com"), 66 | new CacheFirst({ 67 | plugins: [ 68 | new CacheableResponsePlugin({ 69 | statuses: [0, 200], 70 | }), 71 | ], 72 | }) 73 | ); 74 | 75 | // STATIC RESOURCES 76 | 77 | googleFontsCache({ cachePrefix: "wb6-gfonts" }); 78 | 79 | // CONTENT 80 | 81 | imageCache({ cacheName: "wb6-content-images", maxEntries: 10 }); 82 | 83 | // APP SHELL UPDATE FLOW 84 | 85 | addEventListener("message", (event) => { 86 | if (event.data && event.data.type === "SKIP_WAITING") { 87 | self.skipWaiting(); 88 | } 89 | }); 90 | 91 | // BACKGROUND SYNC 92 | 93 | // Instantiating and configuring plugin 94 | const bgSyncPlugin = new BackgroundSyncPlugin("feedbackQueue", { 95 | maxRetentionTime: 24 * 60, // Retry for max of 24 Hours (specified in minutes) 96 | }); 97 | 98 | // Registering a route for retries 99 | registerRoute( 100 | // Alternative notation: ({url}) => url.pathname.startsWith('/post-tweet'), 101 | /(http[s]?:\/\/)?([^\/\s]+\/)post-tweet/, 102 | new NetworkFirst({ 103 | plugins: [bgSyncPlugin], 104 | }), 105 | "POST" 106 | ); 107 | 108 | // ALL OTHER EVENTS 109 | 110 | // Receive push and show a notification 111 | self.addEventListener("push", function (event) { 112 | console.log("[Service Worker]: Received push event", event); 113 | 114 | var notificationData = {}; 115 | 116 | if (event.data.json()) { 117 | notificationData = event.data.json().notification; 118 | } else { 119 | notificationData = { 120 | title: "Something Has Happened", 121 | message: "Something you might want to check out", 122 | icon: "/assets/images/logo.png", 123 | }; 124 | } 125 | 126 | self.registration.showNotification(notificationData.title, notificationData); 127 | }); 128 | 129 | // Custom notification actions 130 | self.addEventListener("notificationclick", function (event) { 131 | console.log("[Service Worker]: Received notificationclick event"); 132 | 133 | event.notification.close(); 134 | 135 | if (event.action == "opentweet") { 136 | console.log("[Service Worker]: Performing action opentweet"); 137 | 138 | event.waitUntil( 139 | clients.openWindow(event.notification.data).then(function (windowClient) { 140 | // do something with the windowClient. 141 | }) 142 | ); 143 | } else { 144 | console.log("[Service Worker]: Performing default click action"); 145 | 146 | // This looks to see if the current is already open and 147 | // focuses if it is 148 | event.waitUntil( 149 | clients 150 | .matchAll({ 151 | includeUncontrolled: true, 152 | type: "window", 153 | }) 154 | .then(function (clientList) { 155 | for (var i = 0; i < clientList.length; i++) { 156 | var client = clientList[i]; 157 | if (client.url == "/" && "focus" in client) return client.focus(); 158 | } 159 | if (clients.openWindow) return clients.openWindow("/"); 160 | }) 161 | ); 162 | } 163 | }); 164 | 165 | // Closing notification action 166 | self.addEventListener("notificationclose", function (event) { 167 | log("[Service Worker]: Received notificationclose event"); 168 | }); 169 | --------------------------------------------------------------------------------