feat: add PWA offline support for recipe pages
- Add service worker with caching for build assets, static files, images, and pages - Add IndexedDB storage for recipes (brief and full data) - Add offline-db API endpoint for bulk recipe download - Add offline sync button component in header - Add offline-shell page for direct navigation fallback - Pre-cache __data.json for client-side navigation - Add +page.ts universal load functions with IndexedDB fallback - Add PWA manifest and icons for installability - Update recipe page to handle missing data gracefully
This commit is contained in:
215
src/lib/components/OfflineSyncButton.svelte
Normal file
215
src/lib/components/OfflineSyncButton.svelte
Normal file
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { pwaStore } from '$lib/stores/pwa.svelte';
|
||||
|
||||
let { lang = 'de' }: { lang?: string } = $props();
|
||||
|
||||
let showTooltip = $state(false);
|
||||
let mounted = $state(false);
|
||||
|
||||
const labels = $derived({
|
||||
syncForOffline: lang === 'en' ? 'Save for offline' : 'Offline speichern',
|
||||
syncing: lang === 'en' ? 'Syncing...' : 'Synchronisiere...',
|
||||
offlineReady: lang === 'en' ? 'Offline ready' : 'Offline bereit',
|
||||
lastSync: lang === 'en' ? 'Last sync' : 'Letzte Sync',
|
||||
recipes: lang === 'en' ? 'recipes' : 'Rezepte',
|
||||
syncNow: lang === 'en' ? 'Sync now' : 'Jetzt synchronisieren',
|
||||
clearData: lang === 'en' ? 'Clear offline data' : 'Offline-Daten löschen'
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
mounted = true;
|
||||
await pwaStore.checkAvailability();
|
||||
});
|
||||
|
||||
async function handleSync() {
|
||||
await pwaStore.syncForOffline();
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
await pwaStore.clearOfflineData();
|
||||
showTooltip = false;
|
||||
}
|
||||
|
||||
function formatDate(isoString: string | null): string {
|
||||
if (!isoString) return '';
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString(lang === 'en' ? 'en-US' : 'de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.offline-sync {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sync-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
transition: color 100ms;
|
||||
}
|
||||
|
||||
.sync-button:hover,
|
||||
.sync-button:focus {
|
||||
color: var(--nord8);
|
||||
}
|
||||
|
||||
.sync-button.syncing {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.sync-button.available {
|
||||
color: var(--nord14);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.sync-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
background: var(--nord0);
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.875rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
.status.ready {
|
||||
color: var(--nord14);
|
||||
}
|
||||
|
||||
.tooltip-button {
|
||||
background: var(--nord3);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background 100ms;
|
||||
}
|
||||
|
||||
.tooltip-button:hover {
|
||||
background: var(--nord2);
|
||||
}
|
||||
|
||||
.tooltip-button.clear {
|
||||
background: var(--nord11);
|
||||
}
|
||||
|
||||
.tooltip-button.clear:hover {
|
||||
background: #c04040;
|
||||
}
|
||||
|
||||
.tooltip-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if mounted}
|
||||
<div class="offline-sync">
|
||||
<button
|
||||
class="sync-button"
|
||||
class:syncing={pwaStore.isSyncing}
|
||||
class:available={pwaStore.isOfflineAvailable}
|
||||
onclick={() => showTooltip = !showTooltip}
|
||||
title={pwaStore.isOfflineAvailable ? labels.offlineReady : labels.syncForOffline}
|
||||
>
|
||||
<svg class="sync-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
{#if pwaStore.isOfflineAvailable}
|
||||
<!-- Checkmark icon when offline data is available -->
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
{:else}
|
||||
<!-- Download icon when no offline data -->
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showTooltip}
|
||||
<div class="tooltip">
|
||||
<div class="tooltip-content">
|
||||
{#if pwaStore.isOfflineAvailable}
|
||||
<div class="status ready">{labels.offlineReady}</div>
|
||||
<div class="meta">
|
||||
{pwaStore.recipeCount} {labels.recipes}
|
||||
{#if pwaStore.lastSyncDate}
|
||||
<br>{labels.lastSync}: {formatDate(pwaStore.lastSyncDate)}
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="tooltip-button"
|
||||
onclick={handleSync}
|
||||
disabled={pwaStore.isSyncing}
|
||||
>
|
||||
{pwaStore.isSyncing ? labels.syncing : labels.syncNow}
|
||||
</button>
|
||||
<button
|
||||
class="tooltip-button clear"
|
||||
onclick={handleClear}
|
||||
disabled={pwaStore.isSyncing}
|
||||
>
|
||||
{labels.clearData}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="status">{labels.syncForOffline}</div>
|
||||
<button
|
||||
class="tooltip-button"
|
||||
onclick={handleSync}
|
||||
disabled={pwaStore.isSyncing}
|
||||
>
|
||||
{pwaStore.isSyncing ? labels.syncing : labels.syncForOffline}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if pwaStore.error}
|
||||
<div class="status" style="color: var(--nord11);">
|
||||
{pwaStore.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
221
src/lib/offline/db.ts
Normal file
221
src/lib/offline/db.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { BriefRecipeType, RecipeModelType } from '../../types/types';
|
||||
|
||||
const DB_NAME = 'bocken-recipes';
|
||||
const DB_VERSION = 2; // Bumped to force recreation of stores
|
||||
|
||||
const STORE_BRIEF = 'recipes_brief';
|
||||
const STORE_FULL = 'recipes_full';
|
||||
const STORE_META = 'meta';
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
// Verify all stores exist
|
||||
if (
|
||||
!db.objectStoreNames.contains(STORE_BRIEF) ||
|
||||
!db.objectStoreNames.contains(STORE_FULL) ||
|
||||
!db.objectStoreNames.contains(STORE_META)
|
||||
) {
|
||||
// Database is corrupted, delete and retry
|
||||
db.close();
|
||||
dbPromise = null;
|
||||
const deleteRequest = indexedDB.deleteDatabase(DB_NAME);
|
||||
deleteRequest.onsuccess = () => {
|
||||
openDB().then(resolve).catch(reject);
|
||||
};
|
||||
deleteRequest.onerror = () => reject(deleteRequest.error);
|
||||
return;
|
||||
}
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// Delete old stores if they exist (clean upgrade)
|
||||
if (db.objectStoreNames.contains(STORE_BRIEF)) {
|
||||
db.deleteObjectStore(STORE_BRIEF);
|
||||
}
|
||||
if (db.objectStoreNames.contains(STORE_FULL)) {
|
||||
db.deleteObjectStore(STORE_FULL);
|
||||
}
|
||||
if (db.objectStoreNames.contains(STORE_META)) {
|
||||
db.deleteObjectStore(STORE_META);
|
||||
}
|
||||
|
||||
// Brief recipes store - keyed by short_name for quick lookups
|
||||
const briefStore = db.createObjectStore(STORE_BRIEF, { keyPath: 'short_name' });
|
||||
briefStore.createIndex('category', 'category', { unique: false });
|
||||
briefStore.createIndex('season', 'season', { unique: false, multiEntry: true });
|
||||
|
||||
// Full recipes store - keyed by short_name
|
||||
db.createObjectStore(STORE_FULL, { keyPath: 'short_name' });
|
||||
|
||||
// Metadata store for sync info
|
||||
db.createObjectStore(STORE_META, { keyPath: 'key' });
|
||||
};
|
||||
});
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function getAllBriefRecipes(): Promise<BriefRecipeType[]> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_BRIEF, 'readonly');
|
||||
const store = tx.objectStore(STORE_BRIEF);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFullRecipe(shortName: string): Promise<RecipeModelType | undefined> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_FULL, 'readonly');
|
||||
const store = tx.objectStore(STORE_FULL);
|
||||
const request = store.get(shortName);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBriefRecipesByCategory(category: string): Promise<BriefRecipeType[]> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_BRIEF, 'readonly');
|
||||
const store = tx.objectStore(STORE_BRIEF);
|
||||
const index = store.index('category');
|
||||
const request = index.getAll(category);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBriefRecipesBySeason(month: number): Promise<BriefRecipeType[]> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_BRIEF, 'readonly');
|
||||
const store = tx.objectStore(STORE_BRIEF);
|
||||
const index = store.index('season');
|
||||
const request = index.getAll(month);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBriefRecipesByTag(tag: string): Promise<BriefRecipeType[]> {
|
||||
const allRecipes = await getAllBriefRecipes();
|
||||
return allRecipes.filter(recipe => recipe.tags?.includes(tag));
|
||||
}
|
||||
|
||||
export async function getBriefRecipesByIcon(icon: string): Promise<BriefRecipeType[]> {
|
||||
const allRecipes = await getAllBriefRecipes();
|
||||
return allRecipes.filter(recipe => recipe.icon === icon);
|
||||
}
|
||||
|
||||
export async function saveAllRecipes(
|
||||
briefRecipes: BriefRecipeType[],
|
||||
fullRecipes: RecipeModelType[]
|
||||
): Promise<void> {
|
||||
const db = await openDB();
|
||||
|
||||
// Clear existing data and save new data in a transaction
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([STORE_BRIEF, STORE_FULL, STORE_META], 'readwrite');
|
||||
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.oncomplete = () => resolve();
|
||||
|
||||
// Clear and repopulate brief recipes
|
||||
const briefStore = tx.objectStore(STORE_BRIEF);
|
||||
briefStore.clear();
|
||||
for (const recipe of briefRecipes) {
|
||||
briefStore.put(recipe);
|
||||
}
|
||||
|
||||
// Clear and repopulate full recipes
|
||||
const fullStore = tx.objectStore(STORE_FULL);
|
||||
fullStore.clear();
|
||||
for (const recipe of fullRecipes) {
|
||||
fullStore.put(recipe);
|
||||
}
|
||||
|
||||
// Update sync metadata
|
||||
const metaStore = tx.objectStore(STORE_META);
|
||||
metaStore.put({
|
||||
key: 'lastSync',
|
||||
value: new Date().toISOString(),
|
||||
recipeCount: briefRecipes.length
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLastSync(): Promise<{ lastSync: string; recipeCount: number } | null> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_META, 'readonly');
|
||||
const store = tx.objectStore(STORE_META);
|
||||
const request = store.get('lastSync');
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
if (request.result) {
|
||||
resolve({
|
||||
lastSync: request.result.value,
|
||||
recipeCount: request.result.recipeCount
|
||||
});
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function isOfflineDataAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const syncInfo = await getLastSync();
|
||||
return syncInfo !== null && syncInfo.recipeCount > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearOfflineData(): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([STORE_BRIEF, STORE_FULL, STORE_META], 'readwrite');
|
||||
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.oncomplete = () => resolve();
|
||||
|
||||
tx.objectStore(STORE_BRIEF).clear();
|
||||
tx.objectStore(STORE_FULL).clear();
|
||||
tx.objectStore(STORE_META).clear();
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRecipeCount(): Promise<number> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_BRIEF, 'readonly');
|
||||
const store = tx.objectStore(STORE_BRIEF);
|
||||
const request = store.count();
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
9
src/lib/offline/helpers.ts
Normal file
9
src/lib/offline/helpers.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export function isOffline(): boolean {
|
||||
return browser && !navigator.onLine;
|
||||
}
|
||||
|
||||
export function canUseOfflineData(): boolean {
|
||||
return browser && typeof indexedDB !== 'undefined';
|
||||
}
|
||||
126
src/lib/offline/sync.ts
Normal file
126
src/lib/offline/sync.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { saveAllRecipes } from './db';
|
||||
import type { BriefRecipeType, RecipeModelType } from '../../types/types';
|
||||
|
||||
export type SyncResult = {
|
||||
success: boolean;
|
||||
recipeCount: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function downloadAllRecipes(
|
||||
fetchFn: typeof fetch = fetch
|
||||
): Promise<SyncResult> {
|
||||
try {
|
||||
const response = await fetchFn('/api/rezepte/offline-db');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch recipes: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: {
|
||||
brief: BriefRecipeType[];
|
||||
full: RecipeModelType[];
|
||||
syncedAt: string;
|
||||
} = await response.json();
|
||||
|
||||
// Save to IndexedDB
|
||||
await saveAllRecipes(data.brief, data.full);
|
||||
|
||||
// Pre-cache the main recipe pages HTML (needed for offline shell)
|
||||
await precacheMainPages(fetchFn);
|
||||
|
||||
// Pre-cache __data.json for all recipes (needed for client-side navigation)
|
||||
await precacheRecipeData(data.brief);
|
||||
|
||||
// Pre-cache thumbnail images via service worker
|
||||
await precacheThumbnails(data.brief);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
recipeCount: data.brief.length
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Offline sync failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
recipeCount: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function precacheMainPages(_fetchFn: typeof fetch): Promise<void> {
|
||||
// Only attempt if service worker is available
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
if (!registration.active) return;
|
||||
|
||||
// Send message to service worker to cache main pages, offline shells, and their data
|
||||
// The offline shells are crucial for direct navigation to recipe pages when offline
|
||||
registration.active.postMessage({
|
||||
type: 'CACHE_PAGES',
|
||||
urls: [
|
||||
'/rezepte',
|
||||
'/recipes',
|
||||
'/rezepte/offline-shell',
|
||||
'/recipes/offline-shell',
|
||||
'/rezepte/__data.json',
|
||||
'/recipes/__data.json'
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
async function precacheRecipeData(recipes: BriefRecipeType[]): Promise<void> {
|
||||
// Only attempt if service worker is available
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
if (!registration.active) return;
|
||||
|
||||
// Collect __data.json URLs for all recipes (both German and English if translated)
|
||||
const dataUrls: string[] = [];
|
||||
for (const recipe of recipes) {
|
||||
// German recipe data
|
||||
dataUrls.push(`/rezepte/${recipe.short_name}/__data.json`);
|
||||
|
||||
// English recipe data (if translation exists)
|
||||
if (recipe.translations?.en?.short_name) {
|
||||
dataUrls.push(`/recipes/${recipe.translations.en.short_name}/__data.json`);
|
||||
}
|
||||
}
|
||||
|
||||
// Send message to service worker to cache these URLs
|
||||
if (dataUrls.length > 0) {
|
||||
registration.active.postMessage({
|
||||
type: 'CACHE_DATA',
|
||||
urls: dataUrls
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function precacheThumbnails(recipes: BriefRecipeType[]): Promise<void> {
|
||||
// Only attempt if service worker is available
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
if (!registration.active) return;
|
||||
|
||||
// Collect all thumbnail URLs
|
||||
const thumbnailUrls: string[] = [];
|
||||
for (const recipe of recipes) {
|
||||
if (recipe.images && recipe.images.length > 0) {
|
||||
const mediapath = recipe.images[0].mediapath;
|
||||
// Thumbnail path format: /static/rezepte/thumb/{short_name}.webp
|
||||
thumbnailUrls.push(`/static/rezepte/thumb/${recipe.short_name}.webp`);
|
||||
}
|
||||
}
|
||||
|
||||
// Send message to service worker to cache these URLs
|
||||
if (thumbnailUrls.length > 0) {
|
||||
registration.active.postMessage({
|
||||
type: 'CACHE_IMAGES',
|
||||
urls: thumbnailUrls
|
||||
});
|
||||
}
|
||||
}
|
||||
96
src/lib/stores/pwa.svelte.ts
Normal file
96
src/lib/stores/pwa.svelte.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { isOfflineDataAvailable, getLastSync, clearOfflineData } from '$lib/offline/db';
|
||||
import { downloadAllRecipes, type SyncResult } from '$lib/offline/sync';
|
||||
|
||||
type PWAState = {
|
||||
isOfflineAvailable: boolean;
|
||||
isSyncing: boolean;
|
||||
lastSyncDate: string | null;
|
||||
recipeCount: number;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
function createPWAStore() {
|
||||
let state = $state<PWAState>({
|
||||
isOfflineAvailable: false,
|
||||
isSyncing: false,
|
||||
lastSyncDate: null,
|
||||
recipeCount: 0,
|
||||
error: null
|
||||
});
|
||||
|
||||
return {
|
||||
get isOfflineAvailable() {
|
||||
return state.isOfflineAvailable;
|
||||
},
|
||||
get isSyncing() {
|
||||
return state.isSyncing;
|
||||
},
|
||||
get lastSyncDate() {
|
||||
return state.lastSyncDate;
|
||||
},
|
||||
get recipeCount() {
|
||||
return state.recipeCount;
|
||||
},
|
||||
get error() {
|
||||
return state.error;
|
||||
},
|
||||
|
||||
async checkAvailability() {
|
||||
try {
|
||||
const available = await isOfflineDataAvailable();
|
||||
state.isOfflineAvailable = available;
|
||||
|
||||
if (available) {
|
||||
const syncInfo = await getLastSync();
|
||||
if (syncInfo) {
|
||||
state.lastSyncDate = syncInfo.lastSync;
|
||||
state.recipeCount = syncInfo.recipeCount;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check offline availability:', error);
|
||||
state.isOfflineAvailable = false;
|
||||
}
|
||||
},
|
||||
|
||||
async syncForOffline(fetchFn: typeof fetch = fetch): Promise<SyncResult> {
|
||||
if (state.isSyncing) {
|
||||
return { success: false, recipeCount: 0, error: 'Sync already in progress' };
|
||||
}
|
||||
|
||||
state.isSyncing = true;
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
const result = await downloadAllRecipes(fetchFn);
|
||||
|
||||
if (result.success) {
|
||||
state.isOfflineAvailable = true;
|
||||
state.lastSyncDate = new Date().toISOString();
|
||||
state.recipeCount = result.recipeCount;
|
||||
} else {
|
||||
state.error = result.error || 'Sync failed';
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
state.isSyncing = false;
|
||||
}
|
||||
},
|
||||
|
||||
async clearOfflineData() {
|
||||
try {
|
||||
await clearOfflineData();
|
||||
state.isOfflineAvailable = false;
|
||||
state.lastSyncDate = null;
|
||||
state.recipeCount = 0;
|
||||
state.error = null;
|
||||
} catch (error) {
|
||||
console.error('Failed to clear offline data:', error);
|
||||
state.error = error instanceof Error ? error.message : 'Failed to clear data';
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const pwaStore = createPWAStore();
|
||||
Reference in New Issue
Block a user