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>
|
||||
|
||||
@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>
|
||||
} @else {
|
||||
<a
|
||||
|
||||
@@ -60,6 +60,23 @@
|
||||
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-flat-button] {
|
||||
margin-left: 0.25rem;
|
||||
|
||||
@@ -10,6 +10,8 @@ import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { map } from 'rxjs';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
@@ -32,6 +34,8 @@ import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.comp
|
||||
export class App implements OnInit {
|
||||
protected readonly auth = inject(AuthService);
|
||||
private readonly breakpointObserver = inject(BreakpointObserver);
|
||||
protected readonly offlineSync = inject(OfflineSyncService);
|
||||
protected readonly onlineStatus = inject(OnlineStatusService);
|
||||
|
||||
protected readonly isCompact = toSignal(
|
||||
this.breakpointObserver.observe('(max-width: 800px)').pipe(map((state) => state.matches)),
|
||||
@@ -43,6 +47,8 @@ export class App implements OnInit {
|
||||
if (this.auth.isAuthenticated()) {
|
||||
this.auth.loadCurrentUser().subscribe({ error: () => undefined });
|
||||
}
|
||||
|
||||
void this.offlineSync.syncNow();
|
||||
}
|
||||
|
||||
protected toggleSidebar(): void {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<header class="assistant-header">
|
||||
<div>
|
||||
<h2>Assistent</h2>
|
||||
<p>Listen planen und erstellen</p>
|
||||
<p>{{ onlineStatus.online() ? 'Listen planen und erstellen' : 'Nur online verfuegbar' }}</p>
|
||||
</div>
|
||||
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
|
||||
<mat-icon aria-hidden="true">{{ onlineStatus.online() ? 'auto_awesome' : 'cloud_off' }}</mat-icon>
|
||||
</header>
|
||||
|
||||
<div class="message-list" aria-live="polite">
|
||||
@@ -50,6 +50,7 @@
|
||||
matInput
|
||||
rows="3"
|
||||
[value]="draft()"
|
||||
[disabled]="!onlineStatus.online()"
|
||||
(input)="draft.set($any($event.target).value)"
|
||||
(keydown.enter)="handleEnter($event)"
|
||||
></textarea>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './assistant.models';
|
||||
import { AssistantService } from './assistant.service';
|
||||
import { ListsService } from '../lists/lists.service';
|
||||
import { OnlineStatusService } from '../offline/online-status.service';
|
||||
import { TemplatesService } from '../templates/templates.service';
|
||||
|
||||
@Component({
|
||||
@@ -33,6 +34,7 @@ import { TemplatesService } from '../templates/templates.service';
|
||||
export class AssistantChatComponent {
|
||||
private readonly assistantService = inject(AssistantService);
|
||||
private readonly listsService = inject(ListsService);
|
||||
protected readonly onlineStatus = inject(OnlineStatusService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly templatesService = inject(TemplatesService);
|
||||
|
||||
@@ -46,13 +48,20 @@ export class AssistantChatComponent {
|
||||
protected readonly sending = signal(false);
|
||||
protected readonly errorMessage = signal<string | null>(null);
|
||||
protected readonly canSend = computed(
|
||||
() => this.draft().trim().length > 0 && !this.sending(),
|
||||
() =>
|
||||
this.draft().trim().length > 0 &&
|
||||
!this.sending() &&
|
||||
this.onlineStatus.online(),
|
||||
);
|
||||
|
||||
protected send(): void {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="loadingSuggestions()"
|
||||
[disabled]="loadingSuggestions() || !onlineStatus.online()"
|
||||
(click)="loadSuggestions()"
|
||||
>
|
||||
@if (loadingSuggestions()) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { getAuthErrorMessage } from '../../auth/error-message';
|
||||
import { PublicUserSearchResult } from '../../auth/auth.models';
|
||||
import { OnboardingService } from '../../onboarding/onboarding.service';
|
||||
import { ConfirmDeleteListDialogComponent } from '../confirm-delete-list-dialog/confirm-delete-list-dialog.component';
|
||||
import { OnlineStatusService } from '../../offline/online-status.service';
|
||||
import {
|
||||
ListItemSuggestion,
|
||||
ListRealtimeEvent,
|
||||
@@ -57,6 +58,7 @@ export class ListDetailComponent implements OnInit {
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly onboarding = inject(OnboardingService);
|
||||
protected readonly onlineStatus = inject(OnlineStatusService);
|
||||
|
||||
protected readonly list = signal<UserList | null>(null);
|
||||
protected readonly isCreateMode = signal(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { of, tap, throwError } from 'rxjs';
|
||||
import {
|
||||
AddListItemRequest,
|
||||
CreateListRequest,
|
||||
@@ -10,45 +11,144 @@ import {
|
||||
UserList,
|
||||
} from './lists.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' })
|
||||
export class ListsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly offlineSync = inject(OfflineSyncService);
|
||||
private readonly onlineStatus = inject(OnlineStatusService);
|
||||
private readonly apiUrl = '/api/lists';
|
||||
|
||||
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> {
|
||||
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> {
|
||||
if (!this.onlineStatus.online()) {
|
||||
return throwError(
|
||||
() => new Error('Neue Listen koennen offline noch nicht erstellt werden.'),
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.post<UserList>(this.apiUrl, data);
|
||||
}
|
||||
|
||||
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 }> {
|
||||
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> {
|
||||
if (!this.onlineStatus.online()) {
|
||||
return throwError(
|
||||
() => new Error('Freigaben koennen offline nicht geaendert werden.'),
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.post<UserList>(`${this.apiUrl}/${listId}/shares`, { userId });
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
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> {
|
||||
if (!this.onlineStatus.online()) {
|
||||
return throwError(
|
||||
() => new Error('Smart Suggestions sind nur online verfuegbar.'),
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.post<ListItemSuggestionsResponse>(
|
||||
`${this.apiUrl}/${listId}/item-suggestions`,
|
||||
{},
|
||||
@@ -56,6 +156,12 @@ export class ListsService {
|
||||
}
|
||||
|
||||
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`, {});
|
||||
}
|
||||
|
||||
@@ -64,9 +170,100 @@ export class ListsService {
|
||||
itemId: string,
|
||||
data: UpdateListItemRequest,
|
||||
): Observable<UserList> {
|
||||
return this.http.patch<UserList>(
|
||||
`${this.apiUrl}/${listId}/items/${itemId}`,
|
||||
data,
|
||||
if (!this.onlineStatus.online()) {
|
||||
const updatedList = this.updateCachedList(listId, (list) => ({
|
||||
...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>
|
||||
<base href="/" />
|
||||
<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="manifest" href="manifest.webmanifest" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
|
||||
@@ -4,3 +4,9 @@ import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.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