From 9ff30b28cddc61ff6ca9512add6be76c17e11032 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Wed, 28 Jan 2026 21:38:10 +0100 Subject: [PATCH] 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 --- src/app.html | 3 + src/lib/components/OfflineSyncButton.svelte | 215 ++++++++++++ src/lib/offline/db.ts | 221 +++++++++++++ src/lib/offline/helpers.ts | 9 + src/lib/offline/sync.ts | 126 +++++++ src/lib/stores/pwa.svelte.ts | 96 ++++++ .../[recipeLang=recipeLang]/+layout.svelte | 2 + src/routes/[recipeLang=recipeLang]/+layout.ts | 34 ++ src/routes/[recipeLang=recipeLang]/+page.ts | 50 +++ .../[name]/+page.server.ts | 17 +- .../[name]/+page.svelte | 55 ++-- .../[recipeLang=recipeLang]/[name]/+page.ts | 80 ++++- .../category/[category]/+page.ts | 40 +++ .../icon/[icon]/+page.ts | 52 +++ .../offline-shell/+page.server.ts | 10 + .../offline-shell/+page.svelte | 68 ++++ .../season/[month]/+page.ts | 41 +++ .../tag/[tag]/+page.ts | 40 +++ src/routes/api/rezepte/offline-db/+server.ts | 79 +++++ src/service-worker.ts | 308 ++++++++++++++++++ src/types/types.ts | 13 + static/favicon-192.png | Bin 0 -> 8132 bytes static/favicon-512.png | Bin 0 -> 35976 bytes static/manifest.json | 24 ++ 24 files changed, 1555 insertions(+), 28 deletions(-) create mode 100644 src/lib/components/OfflineSyncButton.svelte create mode 100644 src/lib/offline/db.ts create mode 100644 src/lib/offline/helpers.ts create mode 100644 src/lib/offline/sync.ts create mode 100644 src/lib/stores/pwa.svelte.ts create mode 100644 src/routes/[recipeLang=recipeLang]/+layout.ts create mode 100644 src/routes/[recipeLang=recipeLang]/+page.ts create mode 100644 src/routes/[recipeLang=recipeLang]/category/[category]/+page.ts create mode 100644 src/routes/[recipeLang=recipeLang]/icon/[icon]/+page.ts create mode 100644 src/routes/[recipeLang=recipeLang]/offline-shell/+page.server.ts create mode 100644 src/routes/[recipeLang=recipeLang]/offline-shell/+page.svelte create mode 100644 src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts create mode 100644 src/routes/[recipeLang=recipeLang]/tag/[tag]/+page.ts create mode 100644 src/routes/api/rezepte/offline-db/+server.ts create mode 100644 src/service-worker.ts create mode 100644 static/favicon-192.png create mode 100644 static/favicon-512.png create mode 100644 static/manifest.json diff --git a/src/app.html b/src/app.html index effe0d0..c6c3f9e 100644 --- a/src/app.html +++ b/src/app.html @@ -4,6 +4,9 @@ + + + %sveltekit.head% diff --git a/src/lib/components/OfflineSyncButton.svelte b/src/lib/components/OfflineSyncButton.svelte new file mode 100644 index 0000000..5b2fb81 --- /dev/null +++ b/src/lib/components/OfflineSyncButton.svelte @@ -0,0 +1,215 @@ + + + + +{#if mounted} +
+ + + {#if showTooltip} +
+
+ {#if pwaStore.isOfflineAvailable} +
{labels.offlineReady}
+
+ {pwaStore.recipeCount} {labels.recipes} + {#if pwaStore.lastSyncDate} +
{labels.lastSync}: {formatDate(pwaStore.lastSyncDate)} + {/if} +
+ + + {:else} +
{labels.syncForOffline}
+ + {/if} + + {#if pwaStore.error} +
+ {pwaStore.error} +
+ {/if} +
+
+ {/if} +
+{/if} diff --git a/src/lib/offline/db.ts b/src/lib/offline/db.ts new file mode 100644 index 0000000..d510c31 --- /dev/null +++ b/src/lib/offline/db.ts @@ -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 | null = null; + +function openDB(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const allRecipes = await getAllBriefRecipes(); + return allRecipes.filter(recipe => recipe.tags?.includes(tag)); +} + +export async function getBriefRecipesByIcon(icon: string): Promise { + const allRecipes = await getAllBriefRecipes(); + return allRecipes.filter(recipe => recipe.icon === icon); +} + +export async function saveAllRecipes( + briefRecipes: BriefRecipeType[], + fullRecipes: RecipeModelType[] +): Promise { + 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 { + try { + const syncInfo = await getLastSync(); + return syncInfo !== null && syncInfo.recipeCount > 0; + } catch { + return false; + } +} + +export async function clearOfflineData(): Promise { + 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 { + 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); + }); +} diff --git a/src/lib/offline/helpers.ts b/src/lib/offline/helpers.ts new file mode 100644 index 0000000..fb8580a --- /dev/null +++ b/src/lib/offline/helpers.ts @@ -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'; +} diff --git a/src/lib/offline/sync.ts b/src/lib/offline/sync.ts new file mode 100644 index 0000000..cebcddf --- /dev/null +++ b/src/lib/offline/sync.ts @@ -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 { + 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 { + // 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 { + // 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 { + // 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 + }); + } +} diff --git a/src/lib/stores/pwa.svelte.ts b/src/lib/stores/pwa.svelte.ts new file mode 100644 index 0000000..55ebe86 --- /dev/null +++ b/src/lib/stores/pwa.svelte.ts @@ -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({ + 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 { + 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(); diff --git a/src/routes/[recipeLang=recipeLang]/+layout.svelte b/src/routes/[recipeLang=recipeLang]/+layout.svelte index 29b43a6..cf9bb07 100644 --- a/src/routes/[recipeLang=recipeLang]/+layout.svelte +++ b/src/routes/[recipeLang=recipeLang]/+layout.svelte @@ -3,6 +3,7 @@ import { page } from '$app/stores'; import Header from '$lib/components/Header.svelte' import UserHeader from '$lib/components/UserHeader.svelte'; import LanguageSelector from '$lib/components/LanguageSelector.svelte'; +import OfflineSyncButton from '$lib/components/OfflineSyncButton.svelte'; let { data, children } = $props(); let user = $derived(data.session?.user); @@ -51,6 +52,7 @@ function isActive(path) { {/snippet} {#snippet right_side()} + {/snippet} diff --git a/src/routes/[recipeLang=recipeLang]/+layout.ts b/src/routes/[recipeLang=recipeLang]/+layout.ts new file mode 100644 index 0000000..8f3896a --- /dev/null +++ b/src/routes/[recipeLang=recipeLang]/+layout.ts @@ -0,0 +1,34 @@ +import { browser } from '$app/environment'; +import { error } from '@sveltejs/kit'; + +export async function load({ params, data }) { + // Validate recipeLang parameter + if (params.recipeLang !== 'rezepte' && params.recipeLang !== 'recipes') { + throw error(404, 'Not found'); + } + + const lang = params.recipeLang === 'recipes' ? 'en' : 'de'; + + // Check if we're offline: + // 1. Browser reports offline (navigator.onLine === false) + // 2. Service worker returned offline flag (data.isOffline === true) + const isClientOffline = browser && (!navigator.onLine || data?.isOffline); + + if (isClientOffline) { + // Return minimal data for offline mode + return { + session: null, + lang, + recipeLang: params.recipeLang, + isOffline: true + }; + } + + // Use server data when available (online mode) + return { + ...data, + lang, + recipeLang: params.recipeLang, + isOffline: false + }; +} diff --git a/src/routes/[recipeLang=recipeLang]/+page.ts b/src/routes/[recipeLang=recipeLang]/+page.ts new file mode 100644 index 0000000..956df2b --- /dev/null +++ b/src/routes/[recipeLang=recipeLang]/+page.ts @@ -0,0 +1,50 @@ +import { browser } from '$app/environment'; +import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; +import { getAllBriefRecipes, getBriefRecipesBySeason, isOfflineDataAvailable } from '$lib/offline/db'; +import { rand_array } from '$lib/js/randomize'; + +export async function load({ data }) { + // On the server, just pass through the server data unchanged + if (!browser) { + return { + ...data, + isOffline: false + }; + } + + // On the client, check if we need to load from IndexedDB + // This happens when: + // 1. We're offline (navigator.onLine is false) + // 2. Service worker returned offline flag + // 3. Server data is missing (e.g., client-side navigation while offline) + const shouldUseOfflineData = (isOffline() || data?.isOffline || !data?.all_brief?.length) && canUseOfflineData(); + + if (shouldUseOfflineData) { + try { + const hasOfflineData = await isOfflineDataAvailable(); + if (hasOfflineData) { + const currentMonth = new Date().getMonth() + 1; + + const [allBrief, seasonRecipes] = await Promise.all([ + getAllBriefRecipes(), + getBriefRecipesBySeason(currentMonth) + ]); + + return { + ...data, + all_brief: rand_array(allBrief), + season: rand_array(seasonRecipes), + isOffline: true + }; + } + } catch (error) { + console.error('Failed to load offline data:', error); + } + } + + // Return server data as-is + return { + ...data, + isOffline: false + }; +} diff --git a/src/routes/[recipeLang=recipeLang]/[name]/+page.server.ts b/src/routes/[recipeLang=recipeLang]/[name]/+page.server.ts index ae46146..eaea8e2 100644 --- a/src/routes/[recipeLang=recipeLang]/[name]/+page.server.ts +++ b/src/routes/[recipeLang=recipeLang]/[name]/+page.server.ts @@ -1,26 +1,35 @@ import { redirect, error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; import { stripHtmlTags } from '$lib/js/stripHtmlTags'; -export async function load({ params, fetch }) { +export const load: PageServerLoad = async ({ fetch, params, locals }) => { const isEnglish = params.recipeLang === 'recipes'; const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte'; const res = await fetch(`${apiBase}/items/${params.name}`); + if (!res.ok) { - const errorData = await res.json().catch(() => ({ message: 'Recipe not found' })); - throw error(res.status, errorData.message); + throw error(res.status, 'Recipe not found'); } const item = await res.json(); + + // Strip HTML for meta tags (server-side only for SEO) const strippedName = stripHtmlTags(item.name); const strippedDescription = stripHtmlTags(item.description); + // Get session for user info + const session = await locals.auth(); + return { item, strippedName, strippedDescription, + lang: isEnglish ? 'en' : 'de', + recipeLang: params.recipeLang, + session }; -} +}; export const actions = { toggleFavorite: async ({ request, locals, url, fetch }) => { diff --git a/src/routes/[recipeLang=recipeLang]/[name]/+page.svelte b/src/routes/[recipeLang=recipeLang]/[name]/+page.svelte index 445a849..f70ce5b 100644 --- a/src/routes/[recipeLang=recipeLang]/[name]/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/[name]/+page.svelte @@ -51,6 +51,11 @@ : ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]); function season_intervals() { + // Guard against missing season data (can happen in offline mode) + if (!data.season || !Array.isArray(data.season) || data.season.length === 0) { + return []; + } + let interval_arr = [] let start_i = 0 @@ -299,8 +304,12 @@ h2{
- {data.category} - {data.icon} + {#if data.category} + {data.category} + {/if} + {#if data.icon} + {data.icon} + {/if}

{@html data.name}

{#if data.description && ! data.preamble}

{data.description}

@@ -308,25 +317,29 @@ h2{ {#if data.preamble}

{@html data.preamble}

{/if} - - -
- {#each data.tags as tag} - {tag} - {/each} -
+ {#if season_iv.length > 0} + + {/if} + {#if data.tags && data.tags.length > 0} + +
+ {#each data.tags as tag} + {tag} + {/each} +
+ {/if} (); + for (const recipe of allRecipes) { + if (recipe.icon) { + iconSet.add(recipe.icon); + } + } + + return { + ...data, + season: rand_array(recipes), + icons: Array.from(iconSet).sort(), + isOffline: true + }; + } + } catch (error) { + console.error('Failed to load offline data:', error); + } + } + + // Return server data as-is + return { + ...data, + isOffline: false + }; +} diff --git a/src/routes/[recipeLang=recipeLang]/offline-shell/+page.server.ts b/src/routes/[recipeLang=recipeLang]/offline-shell/+page.server.ts new file mode 100644 index 0000000..8d2c52b --- /dev/null +++ b/src/routes/[recipeLang=recipeLang]/offline-shell/+page.server.ts @@ -0,0 +1,10 @@ +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ params }) => { + const isEnglish = params.recipeLang === 'recipes'; + + return { + lang: isEnglish ? 'en' : 'de', + recipeLang: params.recipeLang + }; +}; diff --git a/src/routes/[recipeLang=recipeLang]/offline-shell/+page.svelte b/src/routes/[recipeLang=recipeLang]/offline-shell/+page.svelte new file mode 100644 index 0000000..a945166 --- /dev/null +++ b/src/routes/[recipeLang=recipeLang]/offline-shell/+page.svelte @@ -0,0 +1,68 @@ + + +
+
+

{data.lang === 'en' ? 'Loading offline content...' : 'Lade Offline-Inhalte...'}

+
+ + diff --git a/src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts b/src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts new file mode 100644 index 0000000..c7b36ca --- /dev/null +++ b/src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts @@ -0,0 +1,41 @@ +import { browser } from '$app/environment'; +import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; +import { getBriefRecipesBySeason, isOfflineDataAvailable } from '$lib/offline/db'; +import { rand_array } from '$lib/js/randomize'; + +export async function load({ data, params }) { + // On the server, just pass through the server data unchanged + if (!browser) { + return { + ...data, + isOffline: false + }; + } + + // On the client, check if we need to load from IndexedDB + const shouldUseOfflineData = (isOffline() || data?.isOffline || !data?.season?.length) && canUseOfflineData(); + + if (shouldUseOfflineData) { + try { + const hasOfflineData = await isOfflineDataAvailable(); + if (hasOfflineData) { + const month = parseInt(params.month); + const recipes = await getBriefRecipesBySeason(month); + + return { + ...data, + season: rand_array(recipes), + isOffline: true + }; + } + } catch (error) { + console.error('Failed to load offline data:', error); + } + } + + // Return server data as-is + return { + ...data, + isOffline: false + }; +} diff --git a/src/routes/[recipeLang=recipeLang]/tag/[tag]/+page.ts b/src/routes/[recipeLang=recipeLang]/tag/[tag]/+page.ts new file mode 100644 index 0000000..5fdcdbe --- /dev/null +++ b/src/routes/[recipeLang=recipeLang]/tag/[tag]/+page.ts @@ -0,0 +1,40 @@ +import { browser } from '$app/environment'; +import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; +import { getBriefRecipesByTag, isOfflineDataAvailable } from '$lib/offline/db'; +import { rand_array } from '$lib/js/randomize'; + +export async function load({ data, params }) { + // On the server, just pass through the server data unchanged + if (!browser) { + return { + ...data, + isOffline: false + }; + } + + // On the client, check if we need to load from IndexedDB + const shouldUseOfflineData = (isOffline() || data?.isOffline || !data?.recipes?.length) && canUseOfflineData(); + + if (shouldUseOfflineData) { + try { + const hasOfflineData = await isOfflineDataAvailable(); + if (hasOfflineData) { + const recipes = await getBriefRecipesByTag(params.tag); + + return { + ...data, + recipes: rand_array(recipes), + isOffline: true + }; + } + } catch (error) { + console.error('Failed to load offline data:', error); + } + } + + // Return server data as-is + return { + ...data, + isOffline: false + }; +} diff --git a/src/routes/api/rezepte/offline-db/+server.ts b/src/routes/api/rezepte/offline-db/+server.ts new file mode 100644 index 0000000..133c0c1 --- /dev/null +++ b/src/routes/api/rezepte/offline-db/+server.ts @@ -0,0 +1,79 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import type { BriefRecipeType, RecipeModelType } from '../../../../types/types'; +import { Recipe } from '../../../../models/Recipe'; +import { dbConnect } from '../../../../utils/db'; + +export const GET: RequestHandler = async () => { + await dbConnect(); + + // Fetch brief recipes (for lists/filtering) + const briefRecipes = await Recipe.find( + {}, + 'name short_name tags category icon description season dateModified' + ).lean() as BriefRecipeType[]; + + // Fetch full recipes with populated base recipe references + const fullRecipes = await Recipe.find({}) + .populate({ + path: 'ingredients.baseRecipeRef', + select: 'short_name name ingredients translations', + populate: { + path: 'ingredients.baseRecipeRef', + select: 'short_name name ingredients translations', + populate: { + path: 'ingredients.baseRecipeRef', + select: 'short_name name ingredients translations' + } + } + }) + .populate({ + path: 'instructions.baseRecipeRef', + select: 'short_name name instructions translations', + populate: { + path: 'instructions.baseRecipeRef', + select: 'short_name name instructions translations', + populate: { + path: 'instructions.baseRecipeRef', + select: 'short_name name instructions translations' + } + } + }) + .lean() as RecipeModelType[]; + + // Map populated refs to resolvedRecipe field (same as individual item endpoint) + function mapBaseRecipeRefs(items: any[]): any[] { + if (!items) return items; + return items.map((item: any) => { + if (item.type === 'reference' && item.baseRecipeRef) { + const resolvedRecipe = { ...item.baseRecipeRef }; + + if (resolvedRecipe.ingredients) { + resolvedRecipe.ingredients = mapBaseRecipeRefs(resolvedRecipe.ingredients); + } + if (resolvedRecipe.instructions) { + resolvedRecipe.instructions = mapBaseRecipeRefs(resolvedRecipe.instructions); + } + + return { ...item, resolvedRecipe }; + } + return item; + }); + } + + const processedFullRecipes = fullRecipes.map((recipe) => { + const processed = { ...recipe }; + if (processed.ingredients) { + processed.ingredients = mapBaseRecipeRefs(processed.ingredients); + } + if (processed.instructions) { + processed.instructions = mapBaseRecipeRefs(processed.instructions); + } + return processed; + }); + + return json({ + brief: JSON.parse(JSON.stringify(briefRecipes)), + full: JSON.parse(JSON.stringify(processedFullRecipes)), + syncedAt: new Date().toISOString() + }); +}; diff --git a/src/service-worker.ts b/src/service-worker.ts new file mode 100644 index 0000000..c073277 --- /dev/null +++ b/src/service-worker.ts @@ -0,0 +1,308 @@ +/// +/// +/// +/// + +import { build, files, version } from '$service-worker'; + +const sw = self as unknown as ServiceWorkerGlobalScope; + +// Unique cache names +const CACHE_BUILD = `cache-build-${version}`; +const CACHE_STATIC = `cache-static-${version}`; +const CACHE_IMAGES = `cache-images-${version}`; +const CACHE_PAGES = `cache-pages-${version}`; + +// Assets to precache +const buildAssets = new Set(build); +const staticAssets = new Set(files); + +sw.addEventListener('install', (event) => { + event.waitUntil( + Promise.all([ + // Cache build assets (JS, CSS) + caches.open(CACHE_BUILD).then((cache) => cache.addAll(build)), + // Cache static assets (fonts, etc.) - filter out large files + caches.open(CACHE_STATIC).then((cache) => { + const smallStaticFiles = files.filter( + (file) => + !file.endsWith('.json') || + file === '/manifest.json' + ); + return cache.addAll(smallStaticFiles); + }) + ]).then(() => { + // Skip waiting to activate immediately + sw.skipWaiting(); + }) + ); +}); + +sw.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => { + return Promise.all( + keys + .filter((key) => { + // Delete old caches + return ( + key !== CACHE_BUILD && + key !== CACHE_STATIC && + key !== CACHE_IMAGES && + key !== CACHE_PAGES + ); + }) + .map((key) => caches.delete(key)) + ); + }).then(() => { + // Take control of all clients immediately + sw.clients.claim(); + }) + ); +}); + +sw.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Only handle same-origin requests + if (url.origin !== location.origin) return; + + // Skip API requests - let them go to network (IndexedDB handles offline) + if (url.pathname.startsWith('/api/')) return; + + // Handle SvelteKit __data.json requests for recipe routes + // Cache successful responses, serve from cache when offline + if (url.pathname.includes('__data.json') && url.pathname.match(/^\/(rezepte|recipes)/)) { + event.respondWith( + (async () => { + const cache = await caches.open(CACHE_PAGES); + + // Create a cache key without query parameters + // SvelteKit adds ?x-sveltekit-invalidated=... which we need to ignore + const cacheKey = url.pathname; + + try { + // Try network first + const response = await fetch(event.request); + + // Cache successful responses for offline use (using pathname as key) + if (response.ok) { + cache.put(cacheKey, response.clone()); + } + + return response; + } catch { + // Network failed - try to serve from cache (ignoring query params) + const cached = await cache.match(cacheKey); + if (cached) { + return cached; + } + + // No cached data available - return error response + // The page will need to handle this gracefully + return new Response(JSON.stringify({ error: 'offline' }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } + })() + ); + return; + } + + // Handle recipe images (thumbnails and full images) + if ( + url.pathname.startsWith('/static/rezepte/') && + (url.pathname.includes('/thumb/') || url.pathname.includes('/full/')) + ) { + event.respondWith( + caches.open(CACHE_IMAGES).then((cache) => + cache.match(event.request).then((cached) => { + if (cached) return cached; + + return fetch(event.request).then((response) => { + if (response.ok) { + cache.put(event.request, response.clone()); + } + return response; + }).catch(() => { + // Return a placeholder or let the browser handle the error + return new Response('', { status: 404 }); + }); + }) + ) + ); + return; + } + + // For build assets - cache first + if (buildAssets.has(url.pathname)) { + event.respondWith( + caches.match(event.request).then((cached) => { + return cached || fetch(event.request); + }) + ); + return; + } + + // For static assets - cache first + if (staticAssets.has(url.pathname)) { + event.respondWith( + caches.match(event.request).then((cached) => { + return cached || fetch(event.request); + }) + ); + return; + } + + // For navigation requests (HTML pages) - network first, cache response, fallback to cache + if (event.request.mode === 'navigate') { + event.respondWith( + (async () => { + const cache = await caches.open(CACHE_PAGES); + + // Use pathname only for cache key (ignore query params) + const cacheKey = url.pathname; + + try { + // Try network first + const response = await fetch(event.request); + + // Cache successful HTML responses for recipe pages (using pathname as key) + if (response.ok && url.pathname.match(/^\/(rezepte|recipes)(\/|$)/)) { + cache.put(cacheKey, response.clone()); + } + + return response; + } catch { + // Network failed - try to serve from cache (ignoring query params) + const cached = await cache.match(cacheKey); + if (cached) { + return cached; + } + + // For recipe routes, redirect to the offline shell with the target URL + // The offline shell will then do client-side navigation to load from IndexedDB + // Skip if this is already the offline-shell or an offline navigation to prevent loops + const isRecipeRoute = url.pathname.match(/^\/(rezepte|recipes)(\/|$)/); + const isOfflineShell = url.pathname.includes('/offline-shell'); + const isOfflineNavigation = url.searchParams.has('_offline'); + + if (isRecipeRoute && !isOfflineShell && !isOfflineNavigation) { + const isEnglish = url.pathname.startsWith('/recipes'); + const shellPath = isEnglish ? '/recipes/offline-shell' : '/rezepte/offline-shell'; + + // Check if we have the offline shell cached + const shellCached = await cache.match(shellPath); + if (shellCached) { + // Redirect to the offline shell with the original URL as a query param + const redirectUrl = `${shellPath}?redirect=${encodeURIComponent(url.pathname + url.search)}`; + return Response.redirect(redirectUrl, 302); + } + } + + // Last resort - return a basic offline response + return new Response( + 'Offline

Offline

Please connect to the internet and try again.

', + { headers: { 'Content-Type': 'text/html' } } + ); + } + })() + ); + return; + } +}); + +// Handle messages from the app +sw.addEventListener('message', (event) => { + if (event.data?.type === 'CACHE_PAGES') { + const urls: string[] = event.data.urls; + caches.open(CACHE_PAGES).then((cache) => { + Promise.all( + urls.map((url) => + fetch(url, { credentials: 'same-origin' }) + .then((response) => { + if (response.ok) { + return cache.put(url, response); + } + }) + .catch(() => { + // Ignore failed page fetches + }) + ) + ); + }); + } + + if (event.data?.type === 'CACHE_IMAGES') { + const urls: string[] = event.data.urls; + caches.open(CACHE_IMAGES).then((cache) => { + // Cache images in batches to avoid overwhelming the network + const batchSize = 10; + let index = 0; + + function cacheBatch() { + const batch = urls.slice(index, index + batchSize); + if (batch.length === 0) return; + + Promise.all( + batch.map((url) => + fetch(url) + .then((response) => { + if (response.ok) { + return cache.put(url, response); + } + }) + .catch(() => { + // Ignore failed image fetches + }) + ) + ).then(() => { + index += batchSize; + if (index < urls.length) { + // Small delay between batches + setTimeout(cacheBatch, 100); + } + }); + } + + cacheBatch(); + }); + } + + if (event.data?.type === 'CACHE_DATA') { + const urls: string[] = event.data.urls; + caches.open(CACHE_PAGES).then((cache) => { + // Cache __data.json files in batches to avoid overwhelming the network + const batchSize = 20; + let index = 0; + + function cacheBatch() { + const batch = urls.slice(index, index + batchSize); + if (batch.length === 0) return; + + Promise.all( + batch.map((url) => + fetch(url) + .then((response) => { + if (response.ok) { + return cache.put(url, response); + } + }) + .catch(() => { + // Ignore failed fetches + }) + ) + ).then(() => { + index += batchSize; + if (index < urls.length) { + // Small delay between batches + setTimeout(cacheBatch, 50); + } + }); + } + + cacheBatch(); + }); + } +}); diff --git a/src/types/types.ts b/src/types/types.ts index 19c9726..5f84e2a 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -87,6 +87,19 @@ export type TranslatedRecipeType = { note?: string; category: string; tags?: string[]; + portions?: string; + preparation?: string; + cooking?: string; + total_time?: string; + baking?: { + temperature?: string; + length?: string; + mode?: string; + }; + fermentation?: { + bulk?: string; + final?: string; + }; ingredients?: IngredientItem[]; instructions?: InstructionItem[]; images?: [{ diff --git a/static/favicon-192.png b/static/favicon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..696c60d182893b6c53686d6abcf48b83ee2c1614 GIT binary patch literal 8132 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 RGB7YOc)I$ztaD0e0s!u6A`bun literal 0 HcmV?d00001 diff --git a/static/favicon-512.png b/static/favicon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..c3e298297d2c479538527d33004e4d4d3fc1c541 GIT binary patch literal 35976 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

cUA^!f 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 UE1y*f0|Nttr>mdKI;Vst06Ic!U;qFB literal 0 HcmV?d00001 diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..1e846be --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Bocken Rezepte", + "short_name": "Rezepte", + "description": "Eine stetig wachsende Ansammlung an Rezepten aus der Bockenschen Küche", + "start_url": "/rezepte", + "scope": "/", + "display": "standalone", + "theme_color": "#5E81AC", + "background_color": "#2E3440", + "icons": [ + { + "src": "/favicon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/favicon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +}