-
{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}
-
-
{labels.keywords}
-
+ {#if season_iv.length > 0}
+
+ {/if}
+ {#if data.tags && data.tags.length > 0}
+
{labels.keywords}
+
+ {/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(
+ 'OfflineOffline
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"
+ }
+ ]
+}