This commit is contained in:
Bastian Wagner
2026-06-15 11:05:16 +02:00
parent fd7245f1fb
commit b5d5378fc9
15 changed files with 547 additions and 13 deletions

View File

@@ -0,0 +1,18 @@
{
"name": "Listify",
"short_name": "Listify",
"description": "Listen, Templates und Aufgaben organisieren.",
"start_url": "/lists",
"scope": "/",
"display": "standalone",
"background_color": "#fffbfe",
"theme_color": "#6750a4",
"icons": [
{
"src": "/pwa-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#6750a4"/>
<path fill="#ffffff" d="M154 151h220v36H154v-36Zm0 88h220v36H154v-36Zm0 88h160v36H154v-36Z"/>
<path fill="#d0bcff" d="m96 151 22 22 46-54 26 22-70 82-50-50 26-22Zm0 176 22 22 46-54 26 22-70 82-50-50 26-22Z"/>
</svg>

After

Width:  |  Height:  |  Size: 342 B

View File

@@ -0,0 +1,65 @@
const CACHE_NAME = 'listify-shell-v1';
const SHELL_ASSETS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(SHELL_ASSETS))
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const request = event.request;
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) {
return;
}
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', responseClone));
return response;
})
.catch(() => caches.match('/index.html')),
);
return;
}
event.respondWith(
caches.match(request).then((cached) => {
if (cached) {
return cached;
}
return fetch(request).then((response) => {
if (request.method === 'GET' && response.ok) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone));
}
return response;
});
}),
);
});

View File

@@ -19,6 +19,18 @@
<span class="spacer"></span> <span class="spacer"></span>
@if (auth.isAuthenticated()) { @if (auth.isAuthenticated()) {
<span
class="connection-status"
[class.offline]="!onlineStatus.online()"
[title]="onlineStatus.online() ? 'Online' : 'Offline'"
>
<mat-icon aria-hidden="true">
{{ onlineStatus.online() ? 'cloud_done' : 'cloud_off' }}
</mat-icon>
@if (offlineSync.pendingCount() > 0) {
<span>{{ offlineSync.pendingCount() }}</span>
}
</span>
<span class="toolbar-user">{{ auth.user()?.name || auth.user()?.email }}</span> <span class="toolbar-user">{{ auth.user()?.name || auth.user()?.email }}</span>
} @else { } @else {
<a <a

View File

@@ -60,6 +60,23 @@
white-space: nowrap; white-space: nowrap;
} }
.connection-status {
display: inline-flex;
align-items: center;
margin-right: 0.5rem;
color: var(--mat-sys-primary);
}
.connection-status.offline {
color: var(--mat-sys-error);
}
.connection-status mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
.app-toolbar a[mat-button], .app-toolbar a[mat-button],
.app-toolbar a[mat-flat-button] { .app-toolbar a[mat-flat-button] {
margin-left: 0.25rem; margin-left: 0.25rem;

View File

@@ -10,6 +10,8 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { map } from 'rxjs'; import { map } from 'rxjs';
import { AuthService } from './auth/auth.service'; import { AuthService } from './auth/auth.service';
import { AssistantChatComponent } from './assistant/assistant-chat.component'; import { AssistantChatComponent } from './assistant/assistant-chat.component';
import { OfflineSyncService } from './offline/offline-sync.service';
import { OnlineStatusService } from './offline/online-status.service';
import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.component'; import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.component';
@Component({ @Component({
@@ -32,6 +34,8 @@ import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.comp
export class App implements OnInit { export class App implements OnInit {
protected readonly auth = inject(AuthService); protected readonly auth = inject(AuthService);
private readonly breakpointObserver = inject(BreakpointObserver); private readonly breakpointObserver = inject(BreakpointObserver);
protected readonly offlineSync = inject(OfflineSyncService);
protected readonly onlineStatus = inject(OnlineStatusService);
protected readonly isCompact = toSignal( protected readonly isCompact = toSignal(
this.breakpointObserver.observe('(max-width: 800px)').pipe(map((state) => state.matches)), this.breakpointObserver.observe('(max-width: 800px)').pipe(map((state) => state.matches)),
@@ -43,6 +47,8 @@ export class App implements OnInit {
if (this.auth.isAuthenticated()) { if (this.auth.isAuthenticated()) {
this.auth.loadCurrentUser().subscribe({ error: () => undefined }); this.auth.loadCurrentUser().subscribe({ error: () => undefined });
} }
void this.offlineSync.syncNow();
} }
protected toggleSidebar(): void { protected toggleSidebar(): void {

View File

@@ -2,9 +2,9 @@
<header class="assistant-header"> <header class="assistant-header">
<div> <div>
<h2>Assistent</h2> <h2>Assistent</h2>
<p>Listen planen und erstellen</p> <p>{{ onlineStatus.online() ? 'Listen planen und erstellen' : 'Nur online verfuegbar' }}</p>
</div> </div>
<mat-icon aria-hidden="true">auto_awesome</mat-icon> <mat-icon aria-hidden="true">{{ onlineStatus.online() ? 'auto_awesome' : 'cloud_off' }}</mat-icon>
</header> </header>
<div class="message-list" aria-live="polite"> <div class="message-list" aria-live="polite">
@@ -50,6 +50,7 @@
matInput matInput
rows="3" rows="3"
[value]="draft()" [value]="draft()"
[disabled]="!onlineStatus.online()"
(input)="draft.set($any($event.target).value)" (input)="draft.set($any($event.target).value)"
(keydown.enter)="handleEnter($event)" (keydown.enter)="handleEnter($event)"
></textarea> ></textarea>

View File

@@ -15,6 +15,7 @@ import {
} from './assistant.models'; } from './assistant.models';
import { AssistantService } from './assistant.service'; import { AssistantService } from './assistant.service';
import { ListsService } from '../lists/lists.service'; import { ListsService } from '../lists/lists.service';
import { OnlineStatusService } from '../offline/online-status.service';
import { TemplatesService } from '../templates/templates.service'; import { TemplatesService } from '../templates/templates.service';
@Component({ @Component({
@@ -33,6 +34,7 @@ import { TemplatesService } from '../templates/templates.service';
export class AssistantChatComponent { export class AssistantChatComponent {
private readonly assistantService = inject(AssistantService); private readonly assistantService = inject(AssistantService);
private readonly listsService = inject(ListsService); private readonly listsService = inject(ListsService);
protected readonly onlineStatus = inject(OnlineStatusService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly templatesService = inject(TemplatesService); private readonly templatesService = inject(TemplatesService);
@@ -46,13 +48,20 @@ export class AssistantChatComponent {
protected readonly sending = signal(false); protected readonly sending = signal(false);
protected readonly errorMessage = signal<string | null>(null); protected readonly errorMessage = signal<string | null>(null);
protected readonly canSend = computed( protected readonly canSend = computed(
() => this.draft().trim().length > 0 && !this.sending(), () =>
this.draft().trim().length > 0 &&
!this.sending() &&
this.onlineStatus.online(),
); );
protected send(): void { protected send(): void {
const content = this.draft().trim(); const content = this.draft().trim();
if (!content || this.sending()) { if (!content || this.sending() || !this.onlineStatus.online()) {
if (!this.onlineStatus.online()) {
this.errorMessage.set('Der Assistent ist nur online verfuegbar.');
}
return; return;
} }

View File

@@ -259,7 +259,7 @@
<button <button
mat-stroked-button mat-stroked-button
type="button" type="button"
[disabled]="loadingSuggestions()" [disabled]="loadingSuggestions() || !onlineStatus.online()"
(click)="loadSuggestions()" (click)="loadSuggestions()"
> >
@if (loadingSuggestions()) { @if (loadingSuggestions()) {

View File

@@ -18,6 +18,7 @@ import { getAuthErrorMessage } from '../../auth/error-message';
import { PublicUserSearchResult } from '../../auth/auth.models'; import { PublicUserSearchResult } from '../../auth/auth.models';
import { OnboardingService } from '../../onboarding/onboarding.service'; import { OnboardingService } from '../../onboarding/onboarding.service';
import { ConfirmDeleteListDialogComponent } from '../confirm-delete-list-dialog/confirm-delete-list-dialog.component'; import { ConfirmDeleteListDialogComponent } from '../confirm-delete-list-dialog/confirm-delete-list-dialog.component';
import { OnlineStatusService } from '../../offline/online-status.service';
import { import {
ListItemSuggestion, ListItemSuggestion,
ListRealtimeEvent, ListRealtimeEvent,
@@ -57,6 +58,7 @@ export class ListDetailComponent implements OnInit {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
private readonly onboarding = inject(OnboardingService); private readonly onboarding = inject(OnboardingService);
protected readonly onlineStatus = inject(OnlineStatusService);
protected readonly list = signal<UserList | null>(null); protected readonly list = signal<UserList | null>(null);
protected readonly isCreateMode = signal(false); protected readonly isCreateMode = signal(false);

View File

@@ -1,6 +1,7 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { of, tap, throwError } from 'rxjs';
import { import {
AddListItemRequest, AddListItemRequest,
CreateListRequest, CreateListRequest,
@@ -10,45 +11,144 @@ import {
UserList, UserList,
} from './lists.models'; } from './lists.models';
import { ListTemplate } from '../templates/templates.models'; import { ListTemplate } from '../templates/templates.models';
import { OfflineSyncService } from '../offline/offline-sync.service';
import { OnlineStatusService } from '../offline/online-status.service';
const LIST_CACHE_KEY = 'listify.lists.cache.v1';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ListsService { export class ListsService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly offlineSync = inject(OfflineSyncService);
private readonly onlineStatus = inject(OnlineStatusService);
private readonly apiUrl = '/api/lists'; private readonly apiUrl = '/api/lists';
listLists(): Observable<UserList[]> { listLists(): Observable<UserList[]> {
return this.http.get<UserList[]>(this.apiUrl); if (!this.onlineStatus.online()) {
return of(this.readCachedLists());
}
return this.http
.get<UserList[]>(this.apiUrl)
.pipe(tap((lists) => this.writeCachedLists(lists)));
} }
getList(listId: string): Observable<UserList> { getList(listId: string): Observable<UserList> {
return this.http.get<UserList>(`${this.apiUrl}/${listId}`); if (!this.onlineStatus.online()) {
const cachedList = this.readCachedLists().find((list) => list.id === listId);
if (cachedList) {
return of(cachedList);
}
return throwError(() => new Error('Liste ist offline nicht im Cache.'));
}
return this.http
.get<UserList>(`${this.apiUrl}/${listId}`)
.pipe(tap((list) => this.upsertCachedList(list)));
} }
createList(data: CreateListRequest): Observable<UserList> { createList(data: CreateListRequest): Observable<UserList> {
if (!this.onlineStatus.online()) {
return throwError(
() => new Error('Neue Listen koennen offline noch nicht erstellt werden.'),
);
}
return this.http.post<UserList>(this.apiUrl, data); return this.http.post<UserList>(this.apiUrl, data);
} }
updateList(listId: string, data: UpdateListRequest): Observable<UserList> { updateList(listId: string, data: UpdateListRequest): Observable<UserList> {
return this.http.patch<UserList>(`${this.apiUrl}/${listId}`, data); if (!this.onlineStatus.online()) {
const updatedList = this.updateCachedList(listId, (list) => ({
...list,
...data,
updatedAt: new Date().toISOString(),
}));
this.offlineSync.enqueueUpdateList(listId, data);
return updatedList
? of(updatedList)
: throwError(() => new Error('Liste ist offline nicht im Cache.'));
}
return this.http
.patch<UserList>(`${this.apiUrl}/${listId}`, data)
.pipe(tap((list) => this.upsertCachedList(list)));
} }
deleteList(listId: string): Observable<{ message: string }> { deleteList(listId: string): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`${this.apiUrl}/${listId}`); if (!this.onlineStatus.online()) {
this.removeCachedList(listId);
this.offlineSync.enqueueDeleteList(listId);
return of({ message: 'List queued for deletion.' });
}
return this.http
.delete<{ message: string }>(`${this.apiUrl}/${listId}`)
.pipe(tap(() => this.removeCachedList(listId)));
} }
shareList(listId: string, userId: string): Observable<UserList> { shareList(listId: string, userId: string): Observable<UserList> {
if (!this.onlineStatus.online()) {
return throwError(
() => new Error('Freigaben koennen offline nicht geaendert werden.'),
);
}
return this.http.post<UserList>(`${this.apiUrl}/${listId}/shares`, { userId }); return this.http.post<UserList>(`${this.apiUrl}/${listId}/shares`, { userId });
} }
removeShare(listId: string, userId: string): Observable<UserList> { removeShare(listId: string, userId: string): Observable<UserList> {
if (!this.onlineStatus.online()) {
return throwError(
() => new Error('Freigaben koennen offline nicht geaendert werden.'),
);
}
return this.http.delete<UserList>(`${this.apiUrl}/${listId}/shares/${userId}`); return this.http.delete<UserList>(`${this.apiUrl}/${listId}/shares/${userId}`);
} }
addItem(listId: string, data: AddListItemRequest): Observable<UserList> { addItem(listId: string, data: AddListItemRequest): Observable<UserList> {
return this.http.post<UserList>(`${this.apiUrl}/${listId}/items`, data); if (!this.onlineStatus.online()) {
const updatedList = this.updateCachedList(listId, (list) => ({
...list,
items: [
...list.items,
{
id: `offline-${Date.now()}-${Math.random().toString(16).slice(2)}`,
title: data.title,
notes: data.notes,
quantity: data.quantity,
required: data.required ?? true,
checked: false,
position: list.items.length,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
updatedAt: new Date().toISOString(),
}));
this.offlineSync.enqueueAddItem(listId, data);
return updatedList
? of(updatedList)
: throwError(() => new Error('Liste ist offline nicht im Cache.'));
}
return this.http
.post<UserList>(`${this.apiUrl}/${listId}/items`, data)
.pipe(tap((list) => this.upsertCachedList(list)));
} }
suggestItems(listId: string): Observable<ListItemSuggestionsResponse> { suggestItems(listId: string): Observable<ListItemSuggestionsResponse> {
if (!this.onlineStatus.online()) {
return throwError(
() => new Error('Smart Suggestions sind nur online verfuegbar.'),
);
}
return this.http.post<ListItemSuggestionsResponse>( return this.http.post<ListItemSuggestionsResponse>(
`${this.apiUrl}/${listId}/item-suggestions`, `${this.apiUrl}/${listId}/item-suggestions`,
{}, {},
@@ -56,6 +156,12 @@ export class ListsService {
} }
createTemplateFromList(listId: string): Observable<ListTemplate> { createTemplateFromList(listId: string): Observable<ListTemplate> {
if (!this.onlineStatus.online()) {
return throwError(
() => new Error('Templates koennen nur online aus Listen erstellt werden.'),
);
}
return this.http.post<ListTemplate>(`${this.apiUrl}/${listId}/template`, {}); return this.http.post<ListTemplate>(`${this.apiUrl}/${listId}/template`, {});
} }
@@ -64,9 +170,100 @@ export class ListsService {
itemId: string, itemId: string,
data: UpdateListItemRequest, data: UpdateListItemRequest,
): Observable<UserList> { ): Observable<UserList> {
return this.http.patch<UserList>( if (!this.onlineStatus.online()) {
`${this.apiUrl}/${listId}/items/${itemId}`, const updatedList = this.updateCachedList(listId, (list) => ({
data, ...list,
items: list.items.map((item) =>
item.id === itemId
? {
...item,
...data,
checkedAt:
data.checked === true
? new Date().toISOString()
: data.checked === false
? undefined
: item.checkedAt,
updatedAt: new Date().toISOString(),
}
: item,
),
updatedAt: new Date().toISOString(),
}));
if (!itemId.startsWith('offline-')) {
this.offlineSync.enqueueUpdateItem(listId, itemId, data);
}
return updatedList
? of(updatedList)
: throwError(() => new Error('Liste ist offline nicht im Cache.'));
}
return this.http
.patch<UserList>(`${this.apiUrl}/${listId}/items/${itemId}`, data)
.pipe(tap((list) => this.upsertCachedList(list)));
}
private readCachedLists(): UserList[] {
if (typeof localStorage === 'undefined') {
return [];
}
try {
const value = localStorage.getItem(LIST_CACHE_KEY);
return value ? (JSON.parse(value) as UserList[]) : [];
} catch {
return [];
}
}
private writeCachedLists(lists: UserList[]): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(LIST_CACHE_KEY, JSON.stringify(lists));
}
private upsertCachedList(list: UserList): void {
const lists = this.readCachedLists();
const existingIndex = lists.findIndex((existingList) => existingList.id === list.id);
if (existingIndex === -1) {
this.writeCachedLists([...lists, list]);
return;
}
this.writeCachedLists(
lists.map((existingList, index) => (index === existingIndex ? list : existingList)),
);
}
private updateCachedList(
listId: string,
updater: (list: UserList) => UserList,
): UserList | null {
const lists = this.readCachedLists();
let updatedList: UserList | null = null;
this.writeCachedLists(
lists.map((list) => {
if (list.id !== listId) {
return list;
}
updatedList = updater(list);
return updatedList;
}),
);
return updatedList;
}
private removeCachedList(listId: string): void {
this.writeCachedLists(
this.readCachedLists().filter((list) => list.id !== listId),
); );
} }
} }

View File

@@ -0,0 +1,170 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
AddListItemRequest,
UpdateListItemRequest,
UpdateListRequest,
} from '../lists/lists.models';
import { OnlineStatusService } from './online-status.service';
type OfflineOperation =
| {
id: string;
type: 'list.update';
listId: string;
data: UpdateListRequest;
}
| {
id: string;
type: 'list.delete';
listId: string;
}
| {
id: string;
type: 'list.item.add';
listId: string;
data: AddListItemRequest;
}
| {
id: string;
type: 'list.item.update';
listId: string;
itemId: string;
data: UpdateListItemRequest;
};
const QUEUE_KEY = 'listify.offline.queue.v1';
@Injectable({ providedIn: 'root' })
export class OfflineSyncService {
private readonly http = inject(HttpClient);
private readonly onlineStatus = inject(OnlineStatusService);
private readonly queue = signal<OfflineOperation[]>(this.readQueue());
readonly pendingCount = computed(() => this.queue().length);
readonly syncing = signal(false);
constructor() {
if (typeof window !== 'undefined') {
window.addEventListener('online', () => void this.syncNow());
}
}
enqueueUpdateList(listId: string, data: UpdateListRequest): void {
this.enqueue({
id: this.createId(),
type: 'list.update',
listId,
data,
});
}
enqueueDeleteList(listId: string): void {
this.enqueue({
id: this.createId(),
type: 'list.delete',
listId,
});
}
enqueueAddItem(listId: string, data: AddListItemRequest): void {
this.enqueue({
id: this.createId(),
type: 'list.item.add',
listId,
data,
});
}
enqueueUpdateItem(
listId: string,
itemId: string,
data: UpdateListItemRequest,
): void {
this.enqueue({
id: this.createId(),
type: 'list.item.update',
listId,
itemId,
data,
});
}
async syncNow(): Promise<void> {
if (!this.onlineStatus.online() || this.syncing() || this.queue().length === 0) {
return;
}
this.syncing.set(true);
try {
while (this.onlineStatus.online() && this.queue().length > 0) {
const [operation] = this.queue();
await this.sendOperation(operation);
this.queue.update((queue) => queue.slice(1));
this.persistQueue();
}
} finally {
this.syncing.set(false);
}
}
private enqueue(operation: OfflineOperation): void {
this.queue.update((queue) => [...queue, operation]);
this.persistQueue();
}
private async sendOperation(operation: OfflineOperation): Promise<void> {
switch (operation.type) {
case 'list.update':
await firstValueFrom(
this.http.patch(`/api/lists/${operation.listId}`, operation.data),
);
return;
case 'list.delete':
await firstValueFrom(this.http.delete(`/api/lists/${operation.listId}`));
return;
case 'list.item.add':
await firstValueFrom(
this.http.post(`/api/lists/${operation.listId}/items`, operation.data),
);
return;
case 'list.item.update':
await firstValueFrom(
this.http.patch(
`/api/lists/${operation.listId}/items/${operation.itemId}`,
operation.data,
),
);
return;
}
}
private readQueue(): OfflineOperation[] {
if (typeof localStorage === 'undefined') {
return [];
}
try {
const value = localStorage.getItem(QUEUE_KEY);
return value ? (JSON.parse(value) as OfflineOperation[]) : [];
} catch {
return [];
}
}
private persistQueue(): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(QUEUE_KEY, JSON.stringify(this.queue()));
}
private createId(): string {
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable, NgZone, inject, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class OnlineStatusService {
private readonly zone = inject(NgZone);
readonly online = signal(this.readOnlineState());
constructor() {
if (typeof window === 'undefined') {
return;
}
window.addEventListener('online', () => this.setOnline(true));
window.addEventListener('offline', () => this.setOnline(false));
}
private setOnline(online: boolean): void {
this.zone.run(() => this.online.set(online));
}
private readOnlineState(): boolean {
return typeof navigator === 'undefined' ? true : navigator.onLine;
}
}

View File

@@ -5,7 +5,9 @@
<title>ListifyClient</title> <title>ListifyClient</title>
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#6750a4" />
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="manifest" href="manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link

View File

@@ -4,3 +4,9 @@ import { App } from './app/app';
bootstrapApplication(App, appConfig) bootstrapApplication(App, appConfig)
.catch((err) => console.error(err)); .catch((err) => console.error(err));
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => undefined);
});
}