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:
2026-01-28 21:38:10 +01:00
parent 14d217720a
commit be9a8dad16
24 changed files with 1555 additions and 28 deletions
+221
View 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
View 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
View 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
});
}
}