feat: add PWA offline support for recipe pages

- Add service worker with caching for build assets, static files, images, and pages
- Add IndexedDB storage for recipes (brief and full data)
- Add offline-db API endpoint for bulk recipe download
- Add offline sync button component in header
- Add offline-shell page for direct navigation fallback
- Pre-cache __data.json for client-side navigation
- Add +page.ts universal load functions with IndexedDB fallback
- Add PWA manifest and icons for installability
- Update recipe page to handle missing data gracefully
This commit is contained in:
2026-01-28 21:38:10 +01:00
parent 14d217720a
commit be9a8dad16
24 changed files with 1555 additions and 28 deletions
@@ -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()
});
};