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 0000000..696c60d Binary files /dev/null and b/static/favicon-192.png differ diff --git a/static/favicon-512.png b/static/favicon-512.png new file mode 100644 index 0000000..c3e2982 Binary files /dev/null and b/static/favicon-512.png differ 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" + } + ] +}