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:
@@ -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">
|
||||||
|
|||||||
215
src/lib/components/OfflineSyncButton.svelte
Normal file
215
src/lib/components/OfflineSyncButton.svelte
Normal 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
221
src/lib/offline/db.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
9
src/lib/offline/helpers.ts
Normal file
9
src/lib/offline/helpers.ts
Normal 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
126
src/lib/offline/sync.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/lib/stores/pwa.svelte.ts
Normal file
96
src/lib/stores/pwa.svelte.ts
Normal 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();
|
||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
34
src/routes/[recipeLang=recipeLang]/+layout.ts
Normal file
34
src/routes/[recipeLang=recipeLang]/+layout.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
50
src/routes/[recipeLang=recipeLang]/+page.ts
Normal file
50
src/routes/[recipeLang=recipeLang]/+page.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
52
src/routes/[recipeLang=recipeLang]/icon/[icon]/+page.ts
Normal file
52
src/routes/[recipeLang=recipeLang]/icon/[icon]/+page.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
41
src/routes/[recipeLang=recipeLang]/season/[month]/+page.ts
Normal file
41
src/routes/[recipeLang=recipeLang]/season/[month]/+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, 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
|
||||||
|
};
|
||||||
|
}
|
||||||
40
src/routes/[recipeLang=recipeLang]/tag/[tag]/+page.ts
Normal file
40
src/routes/[recipeLang=recipeLang]/tag/[tag]/+page.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
79
src/routes/api/rezepte/offline-db/+server.ts
Normal file
79
src/routes/api/rezepte/offline-db/+server.ts
Normal 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
308
src/service-worker.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
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
BIN
static/favicon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
24
static/manifest.json
Normal file
24
static/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user