diff --git a/src/lib/components/OfflineSyncButton.svelte b/src/lib/components/OfflineSyncButton.svelte
index 9e3d42e..549c84d 100644
--- a/src/lib/components/OfflineSyncButton.svelte
+++ b/src/lib/components/OfflineSyncButton.svelte
@@ -146,6 +146,31 @@
font-size: 0.75rem;
color: var(--nord4);
}
+
+ .progress-container {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ .progress-text {
+ font-size: 0.75rem;
+ color: var(--nord4);
+ }
+
+ .progress-bar {
+ width: 100%;
+ height: 4px;
+ background: var(--nord3);
+ border-radius: 2px;
+ overflow: hidden;
+ }
+
+ .progress-fill {
+ height: 100%;
+ background: var(--nord14);
+ transition: width 150ms ease-out;
+ }
{#if mounted && pwaStore.isStandalone}
@@ -204,6 +229,20 @@
{/if}
+ {#if pwaStore.isSyncing && pwaStore.syncProgress}
+
+
{pwaStore.syncProgress.message}
+ {#if pwaStore.syncProgress.imageProgress}
+
+ {/if}
+
+ {/if}
+
{#if pwaStore.error}
{pwaStore.error}
diff --git a/src/lib/offline/sync.ts b/src/lib/offline/sync.ts
index 008d8ca..bc83a6e 100644
--- a/src/lib/offline/sync.ts
+++ b/src/lib/offline/sync.ts
@@ -12,16 +12,31 @@ const glaubeRoutes = Object.keys(glaubePageModules).map(path => {
.replace('/+page.svelte', '') || '/glaube';
});
+export type SyncProgress = {
+ phase: 'recipes' | 'pages' | 'data' | 'images';
+ message: string;
+ imageProgress?: {
+ completed: number;
+ total: number;
+ };
+};
+
export type SyncResult = {
success: boolean;
recipeCount: number;
error?: string;
};
+export type SyncProgressCallback = (progress: SyncProgress) => void;
+
export async function downloadAllRecipes(
- fetchFn: typeof fetch = fetch
+ fetchFn: typeof fetch = fetch,
+ onProgress?: SyncProgressCallback
): Promise {
try {
+ // Phase 1: Download recipe data
+ onProgress?.({ phase: 'recipes', message: 'Downloading recipes...' });
+
const response = await fetchFn('/api/rezepte/offline-db');
if (!response.ok) {
@@ -36,15 +51,29 @@ export async function downloadAllRecipes(
// Save to IndexedDB
await saveAllRecipes(data.brief, data.full);
+ onProgress?.({ phase: 'recipes', message: `Saved ${data.brief.length} recipes` });
- // Pre-cache the main recipe pages HTML (needed for offline shell)
+ // Phase 2: Pre-cache the main pages HTML
+ onProgress?.({ phase: 'pages', message: 'Caching pages...' });
await precacheMainPages(fetchFn);
- // Pre-cache __data.json for all recipes (needed for client-side navigation)
+ // Phase 3: Pre-cache __data.json for all recipes
+ onProgress?.({ phase: 'data', message: 'Caching navigation data...' });
await precacheRecipeData(data.brief);
- // Pre-cache thumbnail images via service worker
- await precacheThumbnails(data.brief);
+ // Phase 4: Pre-cache thumbnail images via service worker
+ onProgress?.({ phase: 'images', message: 'Caching images...', imageProgress: { completed: 0, total: data.brief.length } });
+
+ await precacheThumbnails(data.brief, (imgProgress) => {
+ onProgress?.({
+ phase: 'images',
+ message: `Caching images (${imgProgress.completed}/${imgProgress.total})...`,
+ imageProgress: {
+ completed: imgProgress.completed,
+ total: imgProgress.total
+ }
+ });
+ });
return {
success: true,
@@ -184,28 +213,71 @@ async function precacheRecipeData(recipes: BriefRecipeType[]): Promise {
}
}
-async function precacheThumbnails(recipes: BriefRecipeType[]): Promise {
+type ImageCacheProgress = {
+ completed: number;
+ total: number;
+ done: boolean;
+};
+
+type ProgressCallback = (progress: ImageCacheProgress) => void;
+
+async function precacheThumbnails(
+ recipes: BriefRecipeType[],
+ onProgress?: ProgressCallback
+): Promise {
// 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
+ // Collect all thumbnail URLs using mediapath (includes hash for cache busting)
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`);
+ // Thumbnail path format: /static/rezepte/thumb/{mediapath}
+ thumbnailUrls.push(`/static/rezepte/thumb/${mediapath}`);
}
}
- // Send message to service worker to cache these URLs
- if (thumbnailUrls.length > 0) {
- registration.active.postMessage({
+ if (thumbnailUrls.length === 0) return;
+
+ // Generate unique request ID
+ const requestId = `img-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+
+ // Create a promise that resolves when caching is complete
+ return new Promise((resolve) => {
+ function handleMessage(event: MessageEvent) {
+ if (event.data?.type === 'CACHE_IMAGES_PROGRESS' && event.data.requestId === requestId) {
+ if (onProgress) {
+ onProgress({
+ completed: event.data.completed,
+ total: event.data.total,
+ done: event.data.done
+ });
+ }
+
+ if (event.data.done) {
+ navigator.serviceWorker.removeEventListener('message', handleMessage);
+ resolve();
+ }
+ }
+ }
+
+ navigator.serviceWorker.addEventListener('message', handleMessage);
+
+ // Send message to service worker to cache these URLs
+ registration.active!.postMessage({
type: 'CACHE_IMAGES',
- urls: thumbnailUrls
+ urls: thumbnailUrls,
+ requestId
});
- }
+
+ // Timeout fallback in case messages don't arrive
+ setTimeout(() => {
+ navigator.serviceWorker.removeEventListener('message', handleMessage);
+ resolve();
+ }, 5 * 60 * 1000); // 5 minute timeout
+ });
}
diff --git a/src/lib/stores/pwa.svelte.ts b/src/lib/stores/pwa.svelte.ts
index f8249f6..374d58a 100644
--- a/src/lib/stores/pwa.svelte.ts
+++ b/src/lib/stores/pwa.svelte.ts
@@ -1,6 +1,6 @@
import { browser } from '$app/environment';
import { isOfflineDataAvailable, getLastSync, clearOfflineData } from '$lib/offline/db';
-import { downloadAllRecipes, type SyncResult } from '$lib/offline/sync';
+import { downloadAllRecipes, type SyncResult, type SyncProgress } from '$lib/offline/sync';
const AUTO_SYNC_INTERVAL = 30 * 60 * 1000; // 30 minutes
const LAST_SYNC_KEY = 'bocken-last-sync-time';
@@ -13,6 +13,7 @@ type PWAState = {
error: string | null;
isStandalone: boolean;
isInitialized: boolean;
+ syncProgress: SyncProgress | null;
};
function createPWAStore() {
@@ -23,7 +24,8 @@ function createPWAStore() {
recipeCount: 0,
error: null,
isStandalone: false,
- isInitialized: false
+ isInitialized: false,
+ syncProgress: null
});
let autoSyncInterval: ReturnType | null = null;
@@ -96,6 +98,9 @@ function createPWAStore() {
get isInitialized() {
return state.isInitialized;
},
+ get syncProgress() {
+ return state.syncProgress;
+ },
async initialize() {
if (!browser || state.isInitialized) return;
@@ -185,9 +190,12 @@ function createPWAStore() {
state.isSyncing = true;
state.error = null;
+ state.syncProgress = null;
try {
- const result = await downloadAllRecipes(fetchFn);
+ const result = await downloadAllRecipes(fetchFn, (progress) => {
+ state.syncProgress = progress;
+ });
if (result.success) {
state.isOfflineAvailable = true;
@@ -201,6 +209,7 @@ function createPWAStore() {
return result;
} finally {
state.isSyncing = false;
+ state.syncProgress = null;
}
},
diff --git a/src/service-worker.ts b/src/service-worker.ts
index d2971a6..04f0f1b 100644
--- a/src/service-worker.ts
+++ b/src/service-worker.ts
@@ -113,27 +113,47 @@ sw.addEventListener('fetch', (event) => {
return;
}
- // Handle recipe images (thumbnails and full images)
+ // Handle recipe images (thumbnails, full images, and placeholders)
if (
url.pathname.startsWith('/static/rezepte/') &&
- (url.pathname.includes('/thumb/') || url.pathname.includes('/full/'))
+ (url.pathname.includes('/thumb/') || url.pathname.includes('/full/') || url.pathname.includes('/placeholder/'))
) {
event.respondWith(
- caches.open(CACHE_IMAGES).then((cache) =>
- cache.match(event.request).then((cached) => {
- if (cached) return cached;
+ (async () => {
+ const cache = await caches.open(CACHE_IMAGES);
- return fetch(event.request).then((response) => {
- if (response.ok) {
+ // Try exact match first
+ const cached = await cache.match(event.request);
+ if (cached) return cached;
+
+ // Try to fetch from network
+ try {
+ const response = await fetch(event.request);
+ if (response.ok) {
+ // Cache thumbnails for offline use
+ if (url.pathname.includes('/thumb/')) {
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 response;
+ } catch {
+ // Network failed - try to serve thumbnail as fallback for full/placeholder
+ if (url.pathname.includes('/full/') || url.pathname.includes('/placeholder/')) {
+ // Extract filename and try to find cached thumbnail
+ const filename = url.pathname.split('/').pop();
+ if (filename) {
+ const thumbUrl = `/static/rezepte/thumb/${filename}`;
+ const thumbCached = await cache.match(thumbUrl);
+ if (thumbCached) {
+ return thumbCached;
+ }
+ }
+ }
+
+ // No fallback available
+ return new Response('', { status: 404 });
+ }
+ })()
);
return;
}
@@ -243,16 +263,35 @@ sw.addEventListener('message', (event) => {
if (event.data?.type === 'CACHE_IMAGES') {
const urls: string[] = event.data.urls;
- caches.open(CACHE_IMAGES).then((cache) => {
+ const requestId = event.data.requestId;
+
+ caches.open(CACHE_IMAGES).then(async (cache) => {
// Cache images in batches to avoid overwhelming the network
const batchSize = 10;
- let index = 0;
+ const total = urls.length;
+ let completed = 0;
- function cacheBatch() {
- const batch = urls.slice(index, index + batchSize);
- if (batch.length === 0) return;
+ // Report progress to all clients
+ async function reportProgress(done: boolean = false) {
+ const clients = await sw.clients.matchAll();
+ for (const client of clients) {
+ client.postMessage({
+ type: 'CACHE_IMAGES_PROGRESS',
+ requestId,
+ completed,
+ total,
+ done
+ });
+ }
+ }
- Promise.all(
+ // Report initial state
+ await reportProgress();
+
+ for (let i = 0; i < urls.length; i += batchSize) {
+ const batch = urls.slice(i, i + batchSize);
+
+ await Promise.all(
batch.map((url) =>
fetch(url)
.then((response) => {
@@ -263,17 +302,23 @@ sw.addEventListener('message', (event) => {
.catch(() => {
// Ignore failed image fetches
})
+ .finally(() => {
+ completed++;
+ })
)
- ).then(() => {
- index += batchSize;
- if (index < urls.length) {
- // Small delay between batches
- setTimeout(cacheBatch, 100);
- }
- });
+ );
+
+ // Report progress after each batch
+ await reportProgress();
+
+ // Small delay between batches
+ if (i + batchSize < urls.length) {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
}
- cacheBatch();
+ // Report completion
+ await reportProgress(true);
});
}