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

@@ -4,6 +4,9 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#5E81AC" />
<link rel="apple-touch-icon" href="/favicon-192.png" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View File

@@ -0,0 +1,215 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pwaStore } from '$lib/stores/pwa.svelte';
let { lang = 'de' }: { lang?: string } = $props();
let showTooltip = $state(false);
let mounted = $state(false);
const labels = $derived({
syncForOffline: lang === 'en' ? 'Save for offline' : 'Offline speichern',
syncing: lang === 'en' ? 'Syncing...' : 'Synchronisiere...',
offlineReady: lang === 'en' ? 'Offline ready' : 'Offline bereit',
lastSync: lang === 'en' ? 'Last sync' : 'Letzte Sync',
recipes: lang === 'en' ? 'recipes' : 'Rezepte',
syncNow: lang === 'en' ? 'Sync now' : 'Jetzt synchronisieren',
clearData: lang === 'en' ? 'Clear offline data' : 'Offline-Daten löschen'
});
onMount(async () => {
mounted = true;
await pwaStore.checkAvailability();
});
async function handleSync() {
await pwaStore.syncForOffline();
}
async function handleClear() {
await pwaStore.clearOfflineData();
showTooltip = false;
}
function formatDate(isoString: string | null): string {
if (!isoString) return '';
const date = new Date(isoString);
return date.toLocaleDateString(lang === 'en' ? 'en-US' : 'de-DE', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
</script>
<style>
.offline-sync {
position: relative;
}
.sync-button {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
transition: color 100ms;
}
.sync-button:hover,
.sync-button:focus {
color: var(--nord8);
}
.sync-button.syncing {
animation: pulse 1s infinite;
}
.sync-button.available {
color: var(--nord14);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.sync-icon {
width: 1.5rem;
height: 1.5rem;
fill: currentColor;
}
.tooltip {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: var(--nord0);
border: 1px solid var(--nord3);
border-radius: 0.5rem;
padding: 1rem;
min-width: 200px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.tooltip-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.status {
font-size: 0.875rem;
color: var(--nord4);
}
.status.ready {
color: var(--nord14);
}
.tooltip-button {
background: var(--nord3);
border: none;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
transition: background 100ms;
}
.tooltip-button:hover {
background: var(--nord2);
}
.tooltip-button.clear {
background: var(--nord11);
}
.tooltip-button.clear:hover {
background: #c04040;
}
.tooltip-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.meta {
font-size: 0.75rem;
color: var(--nord4);
}
</style>
{#if mounted}
<div class="offline-sync">
<button
class="sync-button"
class:syncing={pwaStore.isSyncing}
class:available={pwaStore.isOfflineAvailable}
onclick={() => showTooltip = !showTooltip}
title={pwaStore.isOfflineAvailable ? labels.offlineReady : labels.syncForOffline}
>
<svg class="sync-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
{#if pwaStore.isOfflineAvailable}
<!-- Checkmark icon when offline data is available -->
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
{:else}
<!-- Download icon when no offline data -->
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
{/if}
</svg>
</button>
{#if showTooltip}
<div class="tooltip">
<div class="tooltip-content">
{#if pwaStore.isOfflineAvailable}
<div class="status ready">{labels.offlineReady}</div>
<div class="meta">
{pwaStore.recipeCount} {labels.recipes}
{#if pwaStore.lastSyncDate}
<br>{labels.lastSync}: {formatDate(pwaStore.lastSyncDate)}
{/if}
</div>
<button
class="tooltip-button"
onclick={handleSync}
disabled={pwaStore.isSyncing}
>
{pwaStore.isSyncing ? labels.syncing : labels.syncNow}
</button>
<button
class="tooltip-button clear"
onclick={handleClear}
disabled={pwaStore.isSyncing}
>
{labels.clearData}
</button>
{:else}
<div class="status">{labels.syncForOffline}</div>
<button
class="tooltip-button"
onclick={handleSync}
disabled={pwaStore.isSyncing}
>
{pwaStore.isSyncing ? labels.syncing : labels.syncForOffline}
</button>
{/if}
{#if pwaStore.error}
<div class="status" style="color: var(--nord11);">
{pwaStore.error}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}

221
src/lib/offline/db.ts Normal file
View File

@@ -0,0 +1,221 @@
import type { BriefRecipeType, RecipeModelType } from '../../types/types';
const DB_NAME = 'bocken-recipes';
const DB_VERSION = 2; // Bumped to force recreation of stores
const STORE_BRIEF = 'recipes_brief';
const STORE_FULL = 'recipes_full';
const STORE_META = 'meta';
let dbPromise: Promise<IDBDatabase> | null = null;
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
// Verify all stores exist
if (
!db.objectStoreNames.contains(STORE_BRIEF) ||
!db.objectStoreNames.contains(STORE_FULL) ||
!db.objectStoreNames.contains(STORE_META)
) {
// Database is corrupted, delete and retry
db.close();
dbPromise = null;
const deleteRequest = indexedDB.deleteDatabase(DB_NAME);
deleteRequest.onsuccess = () => {
openDB().then(resolve).catch(reject);
};
deleteRequest.onerror = () => reject(deleteRequest.error);
return;
}
resolve(db);
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Delete old stores if they exist (clean upgrade)
if (db.objectStoreNames.contains(STORE_BRIEF)) {
db.deleteObjectStore(STORE_BRIEF);
}
if (db.objectStoreNames.contains(STORE_FULL)) {
db.deleteObjectStore(STORE_FULL);
}
if (db.objectStoreNames.contains(STORE_META)) {
db.deleteObjectStore(STORE_META);
}
// Brief recipes store - keyed by short_name for quick lookups
const briefStore = db.createObjectStore(STORE_BRIEF, { keyPath: 'short_name' });
briefStore.createIndex('category', 'category', { unique: false });
briefStore.createIndex('season', 'season', { unique: false, multiEntry: true });
// Full recipes store - keyed by short_name
db.createObjectStore(STORE_FULL, { keyPath: 'short_name' });
// Metadata store for sync info
db.createObjectStore(STORE_META, { keyPath: 'key' });
};
});
return dbPromise;
}
export async function getAllBriefRecipes(): Promise<BriefRecipeType[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_BRIEF, 'readonly');
const store = tx.objectStore(STORE_BRIEF);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
export async function getFullRecipe(shortName: string): Promise<RecipeModelType | undefined> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_FULL, 'readonly');
const store = tx.objectStore(STORE_FULL);
const request = store.get(shortName);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
export async function getBriefRecipesByCategory(category: string): Promise<BriefRecipeType[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_BRIEF, 'readonly');
const store = tx.objectStore(STORE_BRIEF);
const index = store.index('category');
const request = index.getAll(category);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
export async function getBriefRecipesBySeason(month: number): Promise<BriefRecipeType[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_BRIEF, 'readonly');
const store = tx.objectStore(STORE_BRIEF);
const index = store.index('season');
const request = index.getAll(month);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
export async function getBriefRecipesByTag(tag: string): Promise<BriefRecipeType[]> {
const allRecipes = await getAllBriefRecipes();
return allRecipes.filter(recipe => recipe.tags?.includes(tag));
}
export async function getBriefRecipesByIcon(icon: string): Promise<BriefRecipeType[]> {
const allRecipes = await getAllBriefRecipes();
return allRecipes.filter(recipe => recipe.icon === icon);
}
export async function saveAllRecipes(
briefRecipes: BriefRecipeType[],
fullRecipes: RecipeModelType[]
): Promise<void> {
const db = await openDB();
// Clear existing data and save new data in a transaction
return new Promise((resolve, reject) => {
const tx = db.transaction([STORE_BRIEF, STORE_FULL, STORE_META], 'readwrite');
tx.onerror = () => reject(tx.error);
tx.oncomplete = () => resolve();
// Clear and repopulate brief recipes
const briefStore = tx.objectStore(STORE_BRIEF);
briefStore.clear();
for (const recipe of briefRecipes) {
briefStore.put(recipe);
}
// Clear and repopulate full recipes
const fullStore = tx.objectStore(STORE_FULL);
fullStore.clear();
for (const recipe of fullRecipes) {
fullStore.put(recipe);
}
// Update sync metadata
const metaStore = tx.objectStore(STORE_META);
metaStore.put({
key: 'lastSync',
value: new Date().toISOString(),
recipeCount: briefRecipes.length
});
});
}
export async function getLastSync(): Promise<{ lastSync: string; recipeCount: number } | null> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_META, 'readonly');
const store = tx.objectStore(STORE_META);
const request = store.get('lastSync');
request.onerror = () => reject(request.error);
request.onsuccess = () => {
if (request.result) {
resolve({
lastSync: request.result.value,
recipeCount: request.result.recipeCount
});
} else {
resolve(null);
}
};
});
}
export async function isOfflineDataAvailable(): Promise<boolean> {
try {
const syncInfo = await getLastSync();
return syncInfo !== null && syncInfo.recipeCount > 0;
} catch {
return false;
}
}
export async function clearOfflineData(): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([STORE_BRIEF, STORE_FULL, STORE_META], 'readwrite');
tx.onerror = () => reject(tx.error);
tx.oncomplete = () => resolve();
tx.objectStore(STORE_BRIEF).clear();
tx.objectStore(STORE_FULL).clear();
tx.objectStore(STORE_META).clear();
});
}
export async function getRecipeCount(): Promise<number> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_BRIEF, 'readonly');
const store = tx.objectStore(STORE_BRIEF);
const request = store.count();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}

View File

@@ -0,0 +1,9 @@
import { browser } from '$app/environment';
export function isOffline(): boolean {
return browser && !navigator.onLine;
}
export function canUseOfflineData(): boolean {
return browser && typeof indexedDB !== 'undefined';
}

126
src/lib/offline/sync.ts Normal file
View File

@@ -0,0 +1,126 @@
import { saveAllRecipes } from './db';
import type { BriefRecipeType, RecipeModelType } from '../../types/types';
export type SyncResult = {
success: boolean;
recipeCount: number;
error?: string;
};
export async function downloadAllRecipes(
fetchFn: typeof fetch = fetch
): Promise<SyncResult> {
try {
const response = await fetchFn('/api/rezepte/offline-db');
if (!response.ok) {
throw new Error(`Failed to fetch recipes: ${response.status}`);
}
const data: {
brief: BriefRecipeType[];
full: RecipeModelType[];
syncedAt: string;
} = await response.json();
// Save to IndexedDB
await saveAllRecipes(data.brief, data.full);
// Pre-cache the main recipe pages HTML (needed for offline shell)
await precacheMainPages(fetchFn);
// Pre-cache __data.json for all recipes (needed for client-side navigation)
await precacheRecipeData(data.brief);
// Pre-cache thumbnail images via service worker
await precacheThumbnails(data.brief);
return {
success: true,
recipeCount: data.brief.length
};
} catch (error) {
console.error('Offline sync failed:', error);
return {
success: false,
recipeCount: 0,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
async function precacheMainPages(_fetchFn: typeof fetch): Promise<void> {
// Only attempt if service worker is available
if (!('serviceWorker' in navigator)) return;
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
registration.active.postMessage({
type: 'CACHE_PAGES',
urls: [
'/rezepte',
'/recipes',
'/rezepte/offline-shell',
'/recipes/offline-shell',
'/rezepte/__data.json',
'/recipes/__data.json'
]
});
}
async function precacheRecipeData(recipes: BriefRecipeType[]): Promise<void> {
// Only attempt if service worker is available
if (!('serviceWorker' in navigator)) return;
const registration = await navigator.serviceWorker.ready;
if (!registration.active) return;
// Collect __data.json URLs for all recipes (both German and English if translated)
const dataUrls: string[] = [];
for (const recipe of recipes) {
// German recipe data
dataUrls.push(`/rezepte/${recipe.short_name}/__data.json`);
// English recipe data (if translation exists)
if (recipe.translations?.en?.short_name) {
dataUrls.push(`/recipes/${recipe.translations.en.short_name}/__data.json`);
}
}
// Send message to service worker to cache these URLs
if (dataUrls.length > 0) {
registration.active.postMessage({
type: 'CACHE_DATA',
urls: dataUrls
});
}
}
async function precacheThumbnails(recipes: BriefRecipeType[]): Promise<void> {
// Only attempt if service worker is available
if (!('serviceWorker' in navigator)) return;
const registration = await navigator.serviceWorker.ready;
if (!registration.active) return;
// Collect all thumbnail URLs
const thumbnailUrls: string[] = [];
for (const recipe of recipes) {
if (recipe.images && recipe.images.length > 0) {
const mediapath = recipe.images[0].mediapath;
// Thumbnail path format: /static/rezepte/thumb/{short_name}.webp
thumbnailUrls.push(`/static/rezepte/thumb/${recipe.short_name}.webp`);
}
}
// Send message to service worker to cache these URLs
if (thumbnailUrls.length > 0) {
registration.active.postMessage({
type: 'CACHE_IMAGES',
urls: thumbnailUrls
});
}
}

View File

@@ -0,0 +1,96 @@
import { isOfflineDataAvailable, getLastSync, clearOfflineData } from '$lib/offline/db';
import { downloadAllRecipes, type SyncResult } from '$lib/offline/sync';
type PWAState = {
isOfflineAvailable: boolean;
isSyncing: boolean;
lastSyncDate: string | null;
recipeCount: number;
error: string | null;
};
function createPWAStore() {
let state = $state<PWAState>({
isOfflineAvailable: false,
isSyncing: false,
lastSyncDate: null,
recipeCount: 0,
error: null
});
return {
get isOfflineAvailable() {
return state.isOfflineAvailable;
},
get isSyncing() {
return state.isSyncing;
},
get lastSyncDate() {
return state.lastSyncDate;
},
get recipeCount() {
return state.recipeCount;
},
get error() {
return state.error;
},
async checkAvailability() {
try {
const available = await isOfflineDataAvailable();
state.isOfflineAvailable = available;
if (available) {
const syncInfo = await getLastSync();
if (syncInfo) {
state.lastSyncDate = syncInfo.lastSync;
state.recipeCount = syncInfo.recipeCount;
}
}
} catch (error) {
console.error('Failed to check offline availability:', error);
state.isOfflineAvailable = false;
}
},
async syncForOffline(fetchFn: typeof fetch = fetch): Promise<SyncResult> {
if (state.isSyncing) {
return { success: false, recipeCount: 0, error: 'Sync already in progress' };
}
state.isSyncing = true;
state.error = null;
try {
const result = await downloadAllRecipes(fetchFn);
if (result.success) {
state.isOfflineAvailable = true;
state.lastSyncDate = new Date().toISOString();
state.recipeCount = result.recipeCount;
} else {
state.error = result.error || 'Sync failed';
}
return result;
} finally {
state.isSyncing = false;
}
},
async clearOfflineData() {
try {
await clearOfflineData();
state.isOfflineAvailable = false;
state.lastSyncDate = null;
state.recipeCount = 0;
state.error = null;
} catch (error) {
console.error('Failed to clear offline data:', error);
state.error = error instanceof Error ? error.message : 'Failed to clear data';
}
}
};
}
export const pwaStore = createPWAStore();

View File

@@ -3,6 +3,7 @@ import { page } from '$app/stores';
import Header from '$lib/components/Header.svelte' import Header from '$lib/components/Header.svelte'
import UserHeader from '$lib/components/UserHeader.svelte'; import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte'; import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import OfflineSyncButton from '$lib/components/OfflineSyncButton.svelte';
let { data, children } = $props(); let { data, children } = $props();
let user = $derived(data.session?.user); let user = $derived(data.session?.user);
@@ -51,6 +52,7 @@ function isActive(path) {
{/snippet} {/snippet}
{#snippet right_side()} {#snippet right_side()}
<OfflineSyncButton lang={data.lang} />
<UserHeader {user} recipeLang={data.recipeLang} lang={data.lang}></UserHeader> <UserHeader {user} recipeLang={data.recipeLang} lang={data.lang}></UserHeader>
{/snippet} {/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 { redirect, error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { stripHtmlTags } from '$lib/js/stripHtmlTags'; 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 isEnglish = params.recipeLang === 'recipes';
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte'; const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
const res = await fetch(`${apiBase}/items/${params.name}`); const res = await fetch(`${apiBase}/items/${params.name}`);
if (!res.ok) { if (!res.ok) {
const errorData = await res.json().catch(() => ({ message: 'Recipe not found' })); throw error(res.status, 'Recipe not found');
throw error(res.status, errorData.message);
} }
const item = await res.json(); const item = await res.json();
// Strip HTML for meta tags (server-side only for SEO)
const strippedName = stripHtmlTags(item.name); const strippedName = stripHtmlTags(item.name);
const strippedDescription = stripHtmlTags(item.description); const strippedDescription = stripHtmlTags(item.description);
// Get session for user info
const session = await locals.auth();
return { return {
item, item,
strippedName, strippedName,
strippedDescription, strippedDescription,
lang: isEnglish ? 'en' : 'de',
recipeLang: params.recipeLang,
session
}; };
} };
export const actions = { export const actions = {
toggleFavorite: async ({ request, locals, url, fetch }) => { 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"]); : ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]);
function season_intervals() { 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 interval_arr = []
let start_i = 0 let start_i = 0
@@ -299,8 +304,12 @@ h2{
<TitleImgParallax src={hero_img_src} {placeholder_src} alt={img_alt}> <TitleImgParallax src={hero_img_src} {placeholder_src} alt={img_alt}>
<div class=title> <div class=title>
<a class="category g-pill g-btn-dark" href='/{data.recipeLang}/category/{data.category}'>{data.category}</a> {#if data.category}
<a class="icon g-icon-badge" href='/{data.recipeLang}/icon/{data.icon}'>{data.icon}</a> <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> <h1>{@html data.name}</h1>
{#if data.description && ! data.preamble} {#if data.description && ! data.preamble}
<p class=description>{data.description}</p> <p class=description>{data.description}</p>
@@ -308,25 +317,29 @@ h2{
{#if data.preamble} {#if data.preamble}
<p>{@html data.preamble}</p> <p>{@html data.preamble}</p>
{/if} {/if}
<div class=tags> {#if season_iv.length > 0}
<h2>{labels.season}</h2> <div class=tags>
{#each season_iv as season} <h2>{labels.season}</h2>
<a class="g-tag" href="/{data.recipeLang}/season/{season[0]}"> {#each season_iv as season}
{#if season[0]} <a class="g-tag" href="/{data.recipeLang}/season/{season[0]}">
{months[season[0] - 1]} {#if season[0]}
{/if} {months[season[0] - 1]}
{#if season[1]} {/if}
- {months[season[1] - 1]} {#if season[1]}
{/if} - {months[season[1] - 1]}
</a> {/if}
{/each} </a>
</div> {/each}
<h2 class="section-label">{labels.keywords}</h2> </div>
<div class="tags center"> {/if}
{#each data.tags as tag} {#if data.tags && data.tags.length > 0}
<a class="g-tag" href="/{data.recipeLang}/tag/{tag}">{tag}</a> <h2 class="section-label">{labels.keywords}</h2>
{/each} <div class="tags center">
</div> {#each data.tags as tag}
<a class="g-tag" href="/{data.recipeLang}/tag/{tag}">{tag}</a>
{/each}
</div>
{/if}
<FavoriteButton <FavoriteButton
recipeId={data.germanShortName} recipeId={data.germanShortName}

View File

@@ -1,10 +1,77 @@
import { browser } from '$app/environment';
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd'; 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 }) { export async function load({ fetch, params, url, data }) {
const isEnglish = params.recipeLang === 'recipes'; const isEnglish = params.recipeLang === 'recipes';
// Use item from server load - no duplicate fetch needed // Check if we need to load from IndexedDB (offline mode)
let item = { ...data.item }; // 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 // Check if this recipe is favorited by the user
let isFavorite = false; let isFavorite = false;
@@ -114,7 +181,11 @@ export async function load({ fetch, params, url, data }) {
const germanShortName = isEnglish ? (item.germanShortName || '') : item.short_name; const germanShortName = isEnglish ? (item.germanShortName || '') : item.short_name;
// Destructure to exclude item (already spread below) // 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 { return {
...serverData, // Include server load data (strippedName, strippedDescription) ...serverData, // Include server load data (strippedName, strippedDescription)
@@ -125,5 +196,8 @@ export async function load({ fetch, params, url, data }) {
hasEnglishTranslation, hasEnglishTranslation,
englishShortName, englishShortName,
germanShortName, 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()
});
};

308
src/service-worker.ts Normal file
View File

@@ -0,0 +1,308 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
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(
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>Offline</title></head><body><h1>Offline</h1><p>Please connect to the internet and try again.</p></body></html>',
{ 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();
});
}
});

View File

@@ -87,6 +87,19 @@ export type TranslatedRecipeType = {
note?: string; note?: string;
category: string; category: string;
tags?: 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[]; ingredients?: IngredientItem[];
instructions?: InstructionItem[]; instructions?: InstructionItem[];
images?: [{ images?: [{

BIN
static/favicon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
static/favicon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

24
static/manifest.json Normal file
View File

@@ -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"
}
]
}