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