refactor: clean up recipe routes and reduce bundle size

- Eliminate duplicate API fetch in recipe page by passing item from
  server load to universal load instead of fetching twice
- Replace cheerio with simple regex in stripHtmlTags, removing ~200KB
  dependency
- Refactor multiplier buttons in IngredientsPage to use loop instead
  of 5 repeated form elements
- Move /rezepte/untranslated to /[recipeLang]/admin/untranslated and
  delete legacy /rezepte/ layout files
This commit is contained in:
2026-01-23 15:04:44 +01:00
parent ab2a6c9158
commit f3b92e8b1a
11 changed files with 58 additions and 252 deletions

View File

@@ -2,18 +2,13 @@ import { redirect, error } from '@sveltejs/kit';
import { stripHtmlTags } from '$lib/js/stripHtmlTags';
export async function load({ params, fetch }) {
// Fetch recipe data to strip HTML tags server-side
// This avoids bundling cheerio in the client bundle
const isEnglish = params.recipeLang === 'recipes';
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
const res = await fetch(`${apiBase}/items/${params.name}`);
if (!res.ok) {
// Let the universal load function handle the error
return {
strippedName: '',
strippedDescription: '',
};
const errorData = await res.json().catch(() => ({ message: 'Recipe not found' }));
throw error(res.status, errorData.message);
}
const item = await res.json();
@@ -21,6 +16,7 @@ export async function load({ params, fetch }) {
const strippedDescription = stripHtmlTags(item.description);
return {
item,
strippedName,
strippedDescription,
};

View File

@@ -1,15 +1,10 @@
import { error } from "@sveltejs/kit";
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
export async function load({ fetch, params, url, data }) {
const isEnglish = params.recipeLang === 'recipes';
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
const res = await fetch(`${apiBase}/items/${params.name}`);
let item = await res.json();
if(!res.ok){
throw error(res.status, item.message)
}
// Use item from server load - no duplicate fetch needed
let item = { ...data.item };
// Check if this recipe is favorited by the user
let isFavorite = false;
@@ -118,8 +113,11 @@ export async function load({ fetch, params, url, data }) {
const englishShortName = !isEnglish ? (item.translations?.en?.short_name || '') : '';
const germanShortName = isEnglish ? (item.germanShortName || '') : item.short_name;
// Destructure to exclude item (already spread below)
const { item: _, ...serverData } = data;
return {
...data, // Include server load data (strippedName, strippedDescription)
...serverData, // Include server load data (strippedName, strippedDescription)
...item,
isFavorite,
multiplier,

View File

@@ -0,0 +1,45 @@
import type { PageServerLoad } from "./$types";
import { redirect, error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ fetch, locals, url, params }) => {
const session = await locals.auth();
// Redirect to login if not authenticated
if (!session?.user?.nickname) {
const callbackUrl = encodeURIComponent(url.pathname);
throw redirect(302, `/login?callbackUrl=${callbackUrl}`);
}
// Check user group permission
if (!session.user.groups?.includes('rezepte_users')) {
throw error(403, 'Zugriff verweigert. Du hast keine Berechtigung für diesen Bereich.');
}
try {
const res = await fetch('/api/rezepte/translate/untranslated');
if (!res.ok) {
return {
untranslated: [],
session,
recipeLang: params.recipeLang,
error: 'Fehler beim Laden der unübersetzten Rezepte'
};
}
const untranslated = await res.json();
return {
untranslated,
session,
recipeLang: params.recipeLang
};
} catch (e) {
return {
untranslated: [],
session,
recipeLang: params.recipeLang,
error: 'Fehler beim Laden der unübersetzten Rezepte'
};
}
};

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import type { PageData } from './$types';
import '$lib/css/nordtheme.css';
import Recipes from '$lib/components/Recipes.svelte';
import Card from '$lib/components/Card.svelte';
let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
// Calculate statistics
const stats = $derived.by(() => {
const noTranslation = data.untranslated.filter(r => !r.translationStatus).length;
const pending = data.untranslated.filter(r => r.translationStatus === 'pending').length;
const needsUpdate = data.untranslated.filter(r => r.translationStatus === 'needs_update').length;
return {
total: data.untranslated.length,
noTranslation,
pending,
needsUpdate
};
});
</script>
<style>
h1 {
text-align: center;
margin-bottom: 0;
font-size: 4rem;
}
.subheading {
text-align: center;
margin-top: 0;
font-size: 1.5rem;
color: var(--nord3);
}
.stats-container {
max-width: 800px;
margin: 2rem auto;
padding: 1.5rem;
background: var(--nord1);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
@media(prefers-color-scheme: light) {
.stats-container {
background: var(--nord6);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.stat-item {
text-align: center;
padding: 1rem;
background: var(--nord0);
border-radius: 6px;
}
@media(prefers-color-scheme: light) {
.stat-item {
background: var(--nord5);
}
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: var(--nord14);
}
.stat-label {
font-size: 0.9rem;
color: var(--nord4);
margin-top: 0.25rem;
}
@media(prefers-color-scheme: light) {
.stat-label {
color: var(--nord2);
}
}
.empty-state {
text-align: center;
margin-top: 3rem;
color: var(--nord3);
}
.empty-state p {
font-size: 1.5rem;
}
</style>
<svelte:head>
<title>Unübersetzte Rezepte - Bocken Rezepte</title>
<meta name="description" content="Verwaltung unübersetzter Rezepte" />
</svelte:head>
<h1>Unübersetzte Rezepte</h1>
{#if data.error}
<p class="subheading">{data.error}</p>
{:else if data.untranslated.length > 0}
<p class="subheading">
{stats.total} {stats.total === 1 ? 'Rezept benötigt' : 'Rezepte benötigen'} Übersetzung oder Überprüfung
</p>
<div class="stats-container">
<h3 style="text-align: center; margin-top: 0;">Statistik</h3>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">{stats.noTranslation}</div>
<div class="stat-label">Keine Übersetzung</div>
</div>
<div class="stat-item">
<div class="stat-value">{stats.pending}</div>
<div class="stat-label">Freigabe ausstehend</div>
</div>
<div class="stat-item">
<div class="stat-value">{stats.needsUpdate}</div>
<div class="stat-label">Aktualisierung erforderlich</div>
</div>
</div>
</div>
<Recipes>
{#each data.untranslated as recipe}
<Card
{recipe}
{current_month}
routePrefix="/{data.recipeLang}"
translationStatus={recipe.translationStatus}
></Card>
{/each}
</Recipes>
{:else}
<div class="empty-state">
<p>Alle Rezepte sind übersetzt!</p>
<p style="font-size: 1rem; margin-top: 1rem;">
<a href="/{data.recipeLang}">Zurück zu den Rezepten</a>
</p>
</div>
{/if}

View File

@@ -16,7 +16,7 @@
description: isEnglish
? 'View and manage recipes that need translation'
: 'Rezepte ansehen und verwalten, die übersetzt werden müssen',
href: '/rezepte/untranslated',
href: `/${data.recipeLang}/admin/untranslated`,
icon: '🌐'
},
{