feat: extend PWA offline support to all recipe routes and glaube pages
- Add offline support for category, tag, icon list pages - Add offline support for favorites page (stores locally for offline) - Add offline support for season list page - Cache root page and glaube pages for offline access - Dynamically discover glaube routes at build time using Vite glob - Add db functions for getAllCategories, getAllTags, getAllIcons - Pre-cache __data.json for all category, tag, icon, season subroutes - Update service worker to cache glaube and root page responses
This commit is contained in:
@@ -127,6 +127,41 @@ export async function getBriefRecipesByIcon(icon: string): Promise<BriefRecipeTy
|
||||
return allRecipes.filter(recipe => recipe.icon === icon);
|
||||
}
|
||||
|
||||
export async function getAllCategories(): Promise<string[]> {
|
||||
const allRecipes = await getAllBriefRecipes();
|
||||
const categories = new Set<string>();
|
||||
for (const recipe of allRecipes) {
|
||||
if (recipe.category) {
|
||||
categories.add(recipe.category);
|
||||
}
|
||||
}
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
|
||||
export async function getAllTags(): Promise<string[]> {
|
||||
const allRecipes = await getAllBriefRecipes();
|
||||
const tags = new Set<string>();
|
||||
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<string[]> {
|
||||
const allRecipes = await getAllBriefRecipes();
|
||||
const icons = new Set<string>();
|
||||
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[]
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
// 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<string>();
|
||||
const tags = new Set<string>();
|
||||
const icons = new Set<string>();
|
||||
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
78
src/routes/[recipeLang=recipeLang]/favorites/+page.ts
Normal file
78
src/routes/[recipeLang=recipeLang]/favorites/+page.ts
Normal file
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
41
src/routes/[recipeLang=recipeLang]/season/+page.ts
Normal file
41
src/routes/[recipeLang=recipeLang]/season/+page.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user