pwa
This commit is contained in:
18
listify-client/public/manifest.webmanifest
Normal file
18
listify-client/public/manifest.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5
listify-client/public/pwa-icon.svg
Normal file
5
listify-client/public/pwa-icon.svg
Normal 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 |
65
listify-client/public/sw.js
Normal file
65
listify-client/public/sw.js
Normal 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;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
170
listify-client/src/app/offline/offline-sync.service.ts
Normal file
170
listify-client/src/app/offline/offline-sync.service.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
listify-client/src/app/offline/online-status.service.ts
Normal file
24
listify-client/src/app/offline/online-status.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user