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 9db2009777
commit 9ff30b28cd
24 changed files with 1555 additions and 28 deletions

View 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
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);
});
}

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
src/lib/offline/sync.ts Normal file
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
});
}
}

View 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();