9ba2996022
Adds a "Save offline" button on the recipe page that force-refetches the current page so the service worker's cache picks up a fresh copy right now, plus a small IndexedDB-backed list (lib/offline-db.ts) of what's been saved. The /offline fallback page now reads that list and renders it instead of being a dead end with just a "go back" link. Also fixes the service worker's network-first fetch handler, which never wrote successful responses into its cache -- meaning the existing offline page's claim that "recently visited recipes are available" was never actually true. It populates the cache on every successful GET now. Background sync: marking a batch-cook dish as cooked while offline (the only existing mark-cooked call site in the app) now queues the request in IndexedDB instead of just failing, and registers a Background Sync (public/sw.js's "sync" listener replays the queue) for Chromium; lib/offline-queue.ts's online-event fallback covers Safari/Firefox, which never fire that event at all. Both replay paths read/write the same IndexedDB store so either one drains it. Also removes apps/web/public/manifest.json, a stale static manifest that layout.tsx used to link to before the previous commit pointed it at the real generated route (app/manifest.ts) -- it had gone unnoticed and unused since. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
101 lines
3.7 KiB
TypeScript
101 lines
3.7 KiB
TypeScript
"use client";
|
|
|
|
// Thin promise wrapper around the browser's native IndexedDB — no added
|
|
// dependency. The service worker (public/sw.js) opens the same database by
|
|
// name/version/store names using the raw API directly (it can't import this
|
|
// module, since it's registered as a classic, non-module script), so any
|
|
// change here must be mirrored there.
|
|
const DB_NAME = "epicure-offline";
|
|
const DB_VERSION = 1;
|
|
export const SAVED_RECIPES_STORE = "savedRecipes";
|
|
export const PENDING_ACTIONS_STORE = "pendingActions";
|
|
|
|
export type SavedRecipe = { id: string; title: string; savedAt: string };
|
|
export type PendingAction = {
|
|
id: string;
|
|
method: string;
|
|
url: string;
|
|
body: unknown;
|
|
description: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
function openDb(): Promise<IDBDatabase> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
req.onupgradeneeded = () => {
|
|
const db = req.result;
|
|
if (!db.objectStoreNames.contains(SAVED_RECIPES_STORE)) {
|
|
db.createObjectStore(SAVED_RECIPES_STORE, { keyPath: "id" });
|
|
}
|
|
if (!db.objectStoreNames.contains(PENDING_ACTIONS_STORE)) {
|
|
db.createObjectStore(PENDING_ACTIONS_STORE, { keyPath: "id" });
|
|
}
|
|
};
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function withStore<T>(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => T): Promise<T> {
|
|
const db = await openDb();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(store, mode);
|
|
const result = fn(tx.objectStore(store));
|
|
tx.oncomplete = () => resolve(result);
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
}
|
|
|
|
export async function saveRecipeOffline(id: string, title: string): Promise<void> {
|
|
await withStore(SAVED_RECIPES_STORE, "readwrite", (s) => {
|
|
s.put({ id, title, savedAt: new Date().toISOString() });
|
|
});
|
|
}
|
|
|
|
export async function unsaveRecipeOffline(id: string): Promise<void> {
|
|
await withStore(SAVED_RECIPES_STORE, "readwrite", (s) => {
|
|
s.delete(id);
|
|
});
|
|
}
|
|
|
|
export async function isRecipeSavedOffline(id: string): Promise<boolean> {
|
|
const record = await withStore<SavedRecipe | undefined>(SAVED_RECIPES_STORE, "readonly", (s) => {
|
|
let result: SavedRecipe | undefined;
|
|
s.get(id).onsuccess = (e) => { result = (e.target as IDBRequest<SavedRecipe | undefined>).result; };
|
|
return result;
|
|
});
|
|
// The transaction's oncomplete fires after the get's onsuccess callback
|
|
// runs, so `record` is populated by the time withStore resolves.
|
|
return record !== undefined;
|
|
}
|
|
|
|
export async function getSavedRecipesOffline(): Promise<SavedRecipe[]> {
|
|
return withStore<SavedRecipe[]>(SAVED_RECIPES_STORE, "readonly", (s) => {
|
|
let result: SavedRecipe[] = [];
|
|
s.getAll().onsuccess = (e) => { result = (e.target as IDBRequest<SavedRecipe[]>).result; };
|
|
return result;
|
|
});
|
|
}
|
|
|
|
export async function enqueuePendingAction(action: Omit<PendingAction, "id" | "createdAt">): Promise<void> {
|
|
await withStore(PENDING_ACTIONS_STORE, "readwrite", (s) => {
|
|
s.put({ ...action, id: crypto.randomUUID(), createdAt: new Date().toISOString() });
|
|
});
|
|
}
|
|
|
|
export async function getPendingActions(): Promise<PendingAction[]> {
|
|
const actions = await withStore<PendingAction[]>(PENDING_ACTIONS_STORE, "readonly", (s) => {
|
|
let result: PendingAction[] = [];
|
|
s.getAll().onsuccess = (e) => { result = (e.target as IDBRequest<PendingAction[]>).result; };
|
|
return result;
|
|
});
|
|
return actions.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
}
|
|
|
|
export async function deletePendingAction(id: string): Promise<void> {
|
|
await withStore(PENDING_ACTIONS_STORE, "readwrite", (s) => {
|
|
s.delete(id);
|
|
});
|
|
}
|