diff --git a/src/lib/offline/db.ts b/src/lib/offline/db.ts index d510c31..ee31c19 100644 --- a/src/lib/offline/db.ts +++ b/src/lib/offline/db.ts @@ -127,6 +127,41 @@ export async function getBriefRecipesByIcon(icon: string): Promise recipe.icon === icon); } +export async function getAllCategories(): Promise { + const allRecipes = await getAllBriefRecipes(); + const categories = new Set(); + for (const recipe of allRecipes) { + if (recipe.category) { + categories.add(recipe.category); + } + } + return Array.from(categories).sort(); +} + +export async function getAllTags(): Promise { + const allRecipes = await getAllBriefRecipes(); + const tags = new Set(); + for (const recipe of allRecipes) { + if (recipe.tags) { + for (const tag of recipe.tags) { + tags.add(tag); + } + } + } + return Array.from(tags).sort(); +} + +export async function getAllIcons(): Promise { + const allRecipes = await getAllBriefRecipes(); + const icons = new Set(); + for (const recipe of allRecipes) { + if (recipe.icon) { + icons.add(recipe.icon); + } + } + return Array.from(icons).sort(); +} + export async function saveAllRecipes( briefRecipes: BriefRecipeType[], fullRecipes: RecipeModelType[] diff --git a/src/lib/offline/sync.ts b/src/lib/offline/sync.ts index cebcddf..008d8ca 100644 --- a/src/lib/offline/sync.ts +++ b/src/lib/offline/sync.ts @@ -1,6 +1,17 @@ import { saveAllRecipes } from './db'; import type { BriefRecipeType, RecipeModelType } from '../../types/types'; +// Discover glaube routes at build time using Vite's glob import +const glaubePageModules = import.meta.glob('/src/routes/glaube/**/+page.svelte'); +const glaubeRoutes = Object.keys(glaubePageModules).map(path => { + // Convert file path to route path + // /src/routes/glaube/+page.svelte -> /glaube + // /src/routes/glaube/angelus/+page.svelte -> /glaube/angelus + return path + .replace('/src/routes', '') + .replace('/+page.svelte', '') || '/glaube'; +}); + export type SyncResult = { success: boolean; recipeCount: number; @@ -56,18 +67,53 @@ async function precacheMainPages(_fetchFn: typeof fetch): Promise { 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 + // Build list of pages to cache + const pagesToCache: string[] = [ + // Root page + '/', + '/__data.json', + // Recipe main pages + '/rezepte', + '/recipes', + '/rezepte/offline-shell', + '/recipes/offline-shell', + // Recipe main page data + '/rezepte/__data.json', + '/recipes/__data.json', + // Recipe list pages + '/rezepte/category', + '/rezepte/tag', + '/rezepte/icon', + '/rezepte/season', + '/rezepte/favorites', + '/recipes/category', + '/recipes/tag', + '/recipes/icon', + '/recipes/season', + '/recipes/favorites', + // Recipe list page data + '/rezepte/category/__data.json', + '/rezepte/tag/__data.json', + '/rezepte/icon/__data.json', + '/rezepte/season/__data.json', + '/rezepte/favorites/__data.json', + '/recipes/category/__data.json', + '/recipes/tag/__data.json', + '/recipes/icon/__data.json', + '/recipes/season/__data.json', + '/recipes/favorites/__data.json' + ]; + + // Add dynamically discovered glaube routes (HTML and __data.json) + for (const route of glaubeRoutes) { + pagesToCache.push(route); + pagesToCache.push(`${route}/__data.json`); + } + + // Send message to service worker to cache all pages registration.active.postMessage({ type: 'CACHE_PAGES', - urls: [ - '/rezepte', - '/recipes', - '/rezepte/offline-shell', - '/recipes/offline-shell', - '/rezepte/__data.json', - '/recipes/__data.json' - ] + urls: pagesToCache }); } @@ -80,6 +126,12 @@ async function precacheRecipeData(recipes: BriefRecipeType[]): Promise { // Collect __data.json URLs for all recipes (both German and English if translated) const dataUrls: string[] = []; + + // Collect unique categories, tags, and icons + const categories = new Set(); + const tags = new Set(); + const icons = new Set(); + for (const recipe of recipes) { // German recipe data dataUrls.push(`/rezepte/${recipe.short_name}/__data.json`); @@ -88,6 +140,39 @@ async function precacheRecipeData(recipes: BriefRecipeType[]): Promise { if (recipe.translations?.en?.short_name) { dataUrls.push(`/recipes/${recipe.translations.en.short_name}/__data.json`); } + + // Collect metadata for subroute caching + if (recipe.category) categories.add(recipe.category); + if (recipe.icon) icons.add(recipe.icon); + if (recipe.tags) { + for (const tag of recipe.tags) { + tags.add(tag); + } + } + } + + // Add category subroute data + for (const category of categories) { + dataUrls.push(`/rezepte/category/${encodeURIComponent(category)}/__data.json`); + dataUrls.push(`/recipes/category/${encodeURIComponent(category)}/__data.json`); + } + + // Add tag subroute data + for (const tag of tags) { + dataUrls.push(`/rezepte/tag/${encodeURIComponent(tag)}/__data.json`); + dataUrls.push(`/recipes/tag/${encodeURIComponent(tag)}/__data.json`); + } + + // Add icon subroute data + for (const icon of icons) { + dataUrls.push(`/rezepte/icon/${encodeURIComponent(icon)}/__data.json`); + dataUrls.push(`/recipes/icon/${encodeURIComponent(icon)}/__data.json`); + } + + // Add season subroute data (all 12 months) + for (let month = 1; month <= 12; month++) { + dataUrls.push(`/rezepte/season/${month}/__data.json`); + dataUrls.push(`/recipes/season/${month}/__data.json`); } // Send message to service worker to cache these URLs diff --git a/src/routes/[recipeLang=recipeLang]/category/+page.ts b/src/routes/[recipeLang=recipeLang]/category/+page.ts index 673905f..4c9bfb2 100644 --- a/src/routes/[recipeLang=recipeLang]/category/+page.ts +++ b/src/routes/[recipeLang=recipeLang]/category/+page.ts @@ -1,10 +1,43 @@ import type { PageLoad } from "./$types"; +import { browser } from '$app/environment'; +import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; +import { getAllCategories, isOfflineDataAvailable } from '$lib/offline/db'; -export async function load({ fetch, params}) { +export const load: PageLoad = async ({ fetch, params }) => { const isEnglish = params.recipeLang === 'recipes'; const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte'; - const res = await fetch(`${apiBase}/items/category`); - const categories= await res.json(); - return {categories} + // Check if we should use offline data + if (browser && isOffline() && canUseOfflineData()) { + try { + const hasOfflineData = await isOfflineDataAvailable(); + if (hasOfflineData) { + const categories = await getAllCategories(); + return { categories, isOffline: true }; + } + } catch (error) { + console.error('Failed to load offline categories:', error); + } + } + + // Online mode - fetch from API + try { + const res = await fetch(`${apiBase}/items/category`); + const categories = await res.json(); + return { categories, isOffline: false }; + } catch (error) { + // Network error - try offline fallback + if (browser && canUseOfflineData()) { + try { + const hasOfflineData = await isOfflineDataAvailable(); + if (hasOfflineData) { + const categories = await getAllCategories(); + return { categories, isOffline: true }; + } + } catch (offlineError) { + console.error('Failed to load offline categories:', offlineError); + } + } + throw error; + } }; diff --git a/src/routes/[recipeLang=recipeLang]/favorites/+page.ts b/src/routes/[recipeLang=recipeLang]/favorites/+page.ts new file mode 100644 index 0000000..971492e --- /dev/null +++ b/src/routes/[recipeLang=recipeLang]/favorites/+page.ts @@ -0,0 +1,78 @@ +import type { PageLoad } from "./$types"; +import { browser } from '$app/environment'; +import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; +import { getAllBriefRecipes, isOfflineDataAvailable } from '$lib/offline/db'; + +// Store favorites in localStorage for offline access +const FAVORITES_STORAGE_KEY = 'bocken-favorites'; + +function getStoredFavorites(): string[] { + if (!browser) return []; + try { + const stored = localStorage.getItem(FAVORITES_STORAGE_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +function storeFavorites(favoriteIds: string[]): void { + if (!browser) return; + try { + localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(favoriteIds)); + } catch { + // Storage full or unavailable + } +} + +export const load: PageLoad = async ({ data, params }) => { + const isEnglish = params.recipeLang === 'recipes'; + + // If we have server data, store the favorite IDs for offline use + if (data?.favorites && Array.isArray(data.favorites) && data.favorites.length > 0) { + const favoriteIds = data.favorites.map((r: any) => r.short_name); + storeFavorites(favoriteIds); + } + + // Check if we should use offline data + const shouldUseOffline = browser && (isOffline() || data?.isOffline) && canUseOfflineData(); + + if (shouldUseOffline) { + try { + const hasOfflineData = await isOfflineDataAvailable(); + if (hasOfflineData) { + const storedFavoriteIds = getStoredFavorites(); + + if (storedFavoriteIds.length === 0) { + return { + ...data, + favorites: [], + isOffline: true, + offlineMessage: isEnglish + ? 'Favorites are not available offline. Please sync while online first.' + : 'Favoriten sind offline nicht verfügbar. Bitte zuerst online synchronisieren.' + }; + } + + const allRecipes = await getAllBriefRecipes(); + const favorites = allRecipes + .filter(recipe => storedFavoriteIds.includes(recipe.short_name)) + .map(recipe => ({ ...recipe, isFavorite: true })); + + return { + ...data, + favorites, + isOffline: true + }; + } + } catch (error) { + console.error('Failed to load offline favorites:', error); + } + } + + // Return server data as-is + return { + ...data, + isOffline: false + }; +}; diff --git a/src/routes/[recipeLang=recipeLang]/icon/+page.ts b/src/routes/[recipeLang=recipeLang]/icon/+page.ts index c3df7f8..5cdf5d3 100644 --- a/src/routes/[recipeLang=recipeLang]/icon/+page.ts +++ b/src/routes/[recipeLang=recipeLang]/icon/+page.ts @@ -1,10 +1,40 @@ import type { PageLoad } from "./$types"; +import { browser } from '$app/environment'; +import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; +import { getAllIcons, isOfflineDataAvailable } from '$lib/offline/db'; -export async function load({ fetch }) { - let current_month = new Date().getMonth() + 1 - const res_icons = await fetch(`/api/rezepte/items/icon`); - const item = await res_icons.json(); - return { - icons: item, - }; +export const load: PageLoad = async ({ fetch }) => { + // Check if we should use offline data + if (browser && isOffline() && canUseOfflineData()) { + try { + const hasOfflineData = await isOfflineDataAvailable(); + if (hasOfflineData) { + const icons = await getAllIcons(); + return { icons, isOffline: true }; + } + } catch (error) { + console.error('Failed to load offline icons:', error); + } + } + + // Online mode - fetch from API + try { + const res_icons = await fetch(`/api/rezepte/items/icon`); + const icons = await res_icons.json(); + return { icons, isOffline: false }; + } catch (error) { + // Network error - try offline fallback + if (browser && canUseOfflineData()) { + try { + const hasOfflineData = await isOfflineDataAvailable(); + if (hasOfflineData) { + const icons = await getAllIcons(); + return { icons, isOffline: true }; + } + } catch (offlineError) { + console.error('Failed to load offline icons:', offlineError); + } + } + throw error; + } }; diff --git a/src/routes/[recipeLang=recipeLang]/season/+page.ts b/src/routes/[recipeLang=recipeLang]/season/+page.ts new file mode 100644 index 0000000..72ea960 --- /dev/null +++ b/src/routes/[recipeLang=recipeLang]/season/+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 }) { + // 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 currentMonth = new Date().getMonth() + 1; + const recipes = await getBriefRecipesBySeason(currentMonth); + + return { + ...data, + season: rand_array(recipes), + isOffline: true + }; + } + } catch (error) { + console.error('Failed to load offline season data:', error); + } + } + + // Return server data as-is + return { + ...data, + isOffline: false + }; +} diff --git a/src/routes/[recipeLang=recipeLang]/tag/+page.ts b/src/routes/[recipeLang=recipeLang]/tag/+page.ts index c669535..0ebcbbb 100644 --- a/src/routes/[recipeLang=recipeLang]/tag/+page.ts +++ b/src/routes/[recipeLang=recipeLang]/tag/+page.ts @@ -1,10 +1,43 @@ import type { PageLoad } from "./$types"; +import { browser } from '$app/environment'; +import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; +import { getAllTags, isOfflineDataAvailable } from '$lib/offline/db'; -export async function load({ fetch, params}) { +export const load: PageLoad = async ({ fetch, params }) => { const isEnglish = params.recipeLang === 'recipes'; const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte'; - const res = await fetch(`${apiBase}/items/tag`); - const tags = await res.json(); - return {tags} + // Check if we should use offline data + if (browser && isOffline() && canUseOfflineData()) { + try { + const hasOfflineData = await isOfflineDataAvailable(); + if (hasOfflineData) { + const tags = await getAllTags(); + return { tags, isOffline: true }; + } + } catch (error) { + console.error('Failed to load offline tags:', error); + } + } + + // Online mode - fetch from API + try { + const res = await fetch(`${apiBase}/items/tag`); + const tags = await res.json(); + return { tags, isOffline: false }; + } catch (error) { + // Network error - try offline fallback + if (browser && canUseOfflineData()) { + try { + const hasOfflineData = await isOfflineDataAvailable(); + if (hasOfflineData) { + const tags = await getAllTags(); + return { tags, isOffline: true }; + } + } catch (offlineError) { + console.error('Failed to load offline tags:', offlineError); + } + } + throw error; + } }; diff --git a/src/service-worker.ts b/src/service-worker.ts index c073277..d2971a6 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -70,9 +70,12 @@ sw.addEventListener('fetch', (event) => { // 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 + // Handle SvelteKit __data.json requests for cacheable routes (recipes, glaube, root) // Cache successful responses, serve from cache when offline - if (url.pathname.includes('__data.json') && url.pathname.match(/^\/(rezepte|recipes)/)) { + const isCacheableDataRoute = url.pathname.includes('__data.json') && + (url.pathname.match(/^\/(rezepte|recipes|glaube)(\/|$)/) || url.pathname === '/__data.json'); + + if (isCacheableDataRoute) { event.respondWith( (async () => { const cache = await caches.open(CACHE_PAGES); @@ -168,8 +171,12 @@ sw.addEventListener('fetch', (event) => { // 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 successful HTML responses for cacheable pages (using pathname as key) + const isCacheablePage = response.ok && ( + url.pathname.match(/^\/(rezepte|recipes|glaube)(\/|$)/) || + url.pathname === '/' + ); + if (isCacheablePage) { cache.put(cacheKey, response.clone()); }