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 9db2009777
commit 9ff30b28cd
24 changed files with 1555 additions and 28 deletions

View File

@@ -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()}
<OfflineSyncButton lang={data.lang} />
<UserHeader {user} recipeLang={data.recipeLang} lang={data.lang}></UserHeader>
{/snippet}

View File

@@ -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
};
}

View File

@@ -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
};
}

View File

@@ -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 }) => {

View File

@@ -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{
<TitleImgParallax src={hero_img_src} {placeholder_src} alt={img_alt}>
<div class=title>
<a class="category g-pill g-btn-dark" href='/{data.recipeLang}/category/{data.category}'>{data.category}</a>
<a class="icon g-icon-badge" href='/{data.recipeLang}/icon/{data.icon}'>{data.icon}</a>
{#if data.category}
<a class="category g-pill g-btn-dark" href='/{data.recipeLang}/category/{data.category}'>{data.category}</a>
{/if}
{#if data.icon}
<a class="icon g-icon-badge" href='/{data.recipeLang}/icon/{data.icon}'>{data.icon}</a>
{/if}
<h1>{@html data.name}</h1>
{#if data.description && ! data.preamble}
<p class=description>{data.description}</p>
@@ -308,25 +317,29 @@ h2{
{#if data.preamble}
<p>{@html data.preamble}</p>
{/if}
<div class=tags>
<h2>{labels.season}</h2>
{#each season_iv as season}
<a class="g-tag" href="/{data.recipeLang}/season/{season[0]}">
{#if season[0]}
{months[season[0] - 1]}
{/if}
{#if season[1]}
- {months[season[1] - 1]}
{/if}
</a>
{/each}
</div>
<h2 class="section-label">{labels.keywords}</h2>
<div class="tags center">
{#each data.tags as tag}
<a class="g-tag" href="/{data.recipeLang}/tag/{tag}">{tag}</a>
{/each}
</div>
{#if season_iv.length > 0}
<div class=tags>
<h2>{labels.season}</h2>
{#each season_iv as season}
<a class="g-tag" href="/{data.recipeLang}/season/{season[0]}">
{#if season[0]}
{months[season[0] - 1]}
{/if}
{#if season[1]}
- {months[season[1] - 1]}
{/if}
</a>
{/each}
</div>
{/if}
{#if data.tags && data.tags.length > 0}
<h2 class="section-label">{labels.keywords}</h2>
<div class="tags center">
{#each data.tags as tag}
<a class="g-tag" href="/{data.recipeLang}/tag/{tag}">{tag}</a>
{/each}
</div>
{/if}
<FavoriteButton
recipeId={data.germanShortName}

View File

@@ -1,10 +1,77 @@
import { browser } from '$app/environment';
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
import { isOffline, canUseOfflineData } from '$lib/offline/helpers';
import { getFullRecipe, isOfflineDataAvailable } from '$lib/offline/db';
import { stripHtmlTags } from '$lib/js/stripHtmlTags';
export async function load({ fetch, params, url, data }) {
const isEnglish = params.recipeLang === 'recipes';
// Use item from server load - no duplicate fetch needed
let item = { ...data.item };
// Check if we need to load from IndexedDB (offline mode)
// Only check on the client side
let item: any;
let isOfflineMode = false;
// On the client, check if we need to load from IndexedDB
const shouldUseOfflineData = browser && (isOffline() || data?.isOffline || !data?.item) && canUseOfflineData();
if (shouldUseOfflineData) {
try {
const hasOfflineData = await isOfflineDataAvailable();
if (hasOfflineData) {
// For English routes, the name param is the English short_name
// We need to find the recipe by its translations.en.short_name or short_name
let recipe = await getFullRecipe(params.name);
if (recipe) {
// Apply English translation if needed
if (isEnglish && recipe.translations?.en) {
const enTrans = recipe.translations.en;
// Use type assertion to avoid tuple/array type mismatch
const recipeAny = recipe as any;
item = {
...recipeAny,
name: enTrans.name || recipe.name,
description: enTrans.description || recipe.description,
preamble: enTrans.preamble || recipe.preamble,
addendum: enTrans.addendum || recipe.addendum,
note: enTrans.note,
category: enTrans.category || recipe.category,
tags: enTrans.tags || recipe.tags,
portions: enTrans.portions || recipe.portions,
preparation: enTrans.preparation || recipe.preparation,
cooking: enTrans.cooking || recipe.cooking,
total_time: enTrans.total_time || recipe.total_time,
baking: enTrans.baking || recipe.baking,
fermentation: enTrans.fermentation || recipe.fermentation,
ingredients: enTrans.ingredients || recipe.ingredients,
instructions: enTrans.instructions || recipe.instructions,
germanShortName: recipe.short_name
};
} else {
item = recipe;
}
isOfflineMode = true;
}
}
} catch (error) {
console.error('Failed to load offline recipe:', error);
}
}
// Use server data if not offline or offline load failed
if (!item && data?.item) {
item = { ...data.item };
}
// If still no item, we're offline without cached data - return error state
if (!item) {
return {
...data,
isOffline: true,
error: 'Recipe not available offline'
};
}
// Check if this recipe is favorited by the user
let isFavorite = false;
@@ -114,7 +181,11 @@ export async function load({ fetch, params, url, data }) {
const germanShortName = isEnglish ? (item.germanShortName || '') : item.short_name;
// Destructure to exclude item (already spread below)
const { item: _, ...serverData } = data;
const { item: _, ...serverData } = data || {};
// For offline mode, generate stripped versions locally
const strippedName = isOfflineMode ? stripHtmlTags(item.name) : (serverData as any)?.strippedName;
const strippedDescription = isOfflineMode ? stripHtmlTags(item.description) : (serverData as any)?.strippedDescription;
return {
...serverData, // Include server load data (strippedName, strippedDescription)
@@ -125,5 +196,8 @@ export async function load({ fetch, params, url, data }) {
hasEnglishTranslation,
englishShortName,
germanShortName,
strippedName,
strippedDescription,
isOffline: isOfflineMode,
};
}

View File

@@ -0,0 +1,40 @@
import { browser } from '$app/environment';
import { isOffline, canUseOfflineData } from '$lib/offline/helpers';
import { getBriefRecipesByCategory, 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 getBriefRecipesByCategory(params.category);
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
};
}

View File

@@ -0,0 +1,52 @@
import { browser } from '$app/environment';
import { isOffline, canUseOfflineData } from '$lib/offline/helpers';
import { getBriefRecipesByIcon, getAllBriefRecipes, 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 [recipes, allRecipes] = await Promise.all([
getBriefRecipesByIcon(params.icon),
getAllBriefRecipes()
]);
// Extract unique icons from all recipes
const iconSet = new Set<string>();
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
};
}

View File

@@ -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
};
};

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let { data } = $props();
// This page serves as an "app shell" that gets cached by the service worker.
// When a user directly navigates to a recipe page while offline and that exact
// page isn't cached, the service worker serves this shell instead.
// On mount, we redirect to the actual requested URL using client-side navigation.
onMount(() => {
// Only proceed if we're actually offline or have a redirect target
// This prevents issues if someone navigates here directly while online
const targetUrl = $page.url.searchParams.get('redirect');
if (!targetUrl) {
// No redirect target - just go to main recipe list
goto(`/${data.recipeLang}`, { replaceState: true });
return;
}
// Wait for hydration to complete, then navigate
tick().then(() => {
// Add _offline marker to prevent service worker redirect loop
const urlWithMarker = new URL(targetUrl, window.location.origin);
urlWithMarker.searchParams.set('_offline', '1');
// Navigate to the actual requested page using client-side routing
// This will trigger the +page.ts which loads data from IndexedDB
goto(urlWithMarker.pathname + urlWithMarker.search, { replaceState: true });
});
});
</script>
<div class="offline-shell">
<div class="loading-spinner"></div>
<p>{data.lang === 'en' ? 'Loading offline content...' : 'Lade Offline-Inhalte...'}</p>
</div>
<style>
.offline-shell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 50vh;
gap: 1rem;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--nord4, #d8dee9);
border-top-color: var(--nord10, #5e81ac);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
p {
color: var(--nord4, #d8dee9);
}
</style>

View 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, 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
};
}

View File

@@ -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
};
}

View File

@@ -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()
});
};