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:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user