From ee387159ba1a1767a0726f30ccfffda38a5842fa Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sat, 3 Jan 2026 20:03:21 +0100 Subject: [PATCH] feat: add untranslated recipes page for recipe admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new page at /rezepte/untranslated for recipe admins to view and manage recipes without approved English translations. Includes translation status tracking, statistics dashboard, and visual badges. Changes: - Add API endpoint to fetch recipes without approved translations - Create untranslated recipes page with auth checks for rezepte_users group - Add translation status badges to Card component (pending, needs_update, none) - Add database index on translations.en.translationStatus for performance - Create layout for /rezepte route with header navigation - Add "Unübersetzt" link to navigation for authorized users --- src/lib/components/Card.svelte | 39 ++++- src/models/Recipe.ts | 1 + .../rezepte/translate/untranslated/+server.ts | 48 ++++++ src/routes/rezepte/+layout.server.ts | 9 ++ src/routes/rezepte/+layout.svelte | 46 ++++++ .../rezepte/untranslated/+page.server.ts | 41 +++++ src/routes/rezepte/untranslated/+page.svelte | 142 ++++++++++++++++++ 7 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 src/routes/api/rezepte/translate/untranslated/+server.ts create mode 100644 src/routes/rezepte/+layout.server.ts create mode 100644 src/routes/rezepte/+layout.svelte create mode 100644 src/routes/rezepte/untranslated/+page.server.ts create mode 100644 src/routes/rezepte/untranslated/+page.svelte diff --git a/src/lib/components/Card.svelte b/src/lib/components/Card.svelte index 8530219f..b685a680 100644 --- a/src/lib/components/Card.svelte +++ b/src/lib/components/Card.svelte @@ -13,7 +13,8 @@ let { isFavorite = false, showFavoriteIndicator = false, loading_strat = "lazy", - routePrefix = '/rezepte' + routePrefix = '/rezepte', + translationStatus = undefined } = $props(); // Make current_month reactive based on icon_override @@ -232,6 +233,31 @@ const img_name = $derived( filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); } +.translation-badge{ + position: absolute; + top: 0.5rem; + right: 0.5rem; + padding: 0.4rem 0.8rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + z-index: 3; + color: var(--nord0); +} + +.translation-badge.none{ + background-color: var(--nord14); +} + +.translation-badge.pending{ + background-color: var(--nord13); +} + +.translation-badge.needs_update{ + background-color: var(--nord12); +} + .icon:hover, .icon:focus-visible { @@ -270,6 +296,17 @@ const img_name = $derived( {#if showFavoriteIndicator && isFavorite}
❤️
{/if} + {#if translationStatus !== undefined} +
+ {#if translationStatus === 'pending'} + Freigabe ausstehend + {:else if translationStatus === 'needs_update'} + Aktualisierung erforderlich + {:else} + Keine Übersetzung + {/if} +
+ {/if} {#if icon_override || recipe.season.includes(current_month)} {recipe.icon} {/if} diff --git a/src/models/Recipe.ts b/src/models/Recipe.ts index e44f56a2..4ea9214f 100644 --- a/src/models/Recipe.ts +++ b/src/models/Recipe.ts @@ -100,5 +100,6 @@ const RecipeSchema = new mongoose.Schema( // Indexes for efficient querying RecipeSchema.index({ "translations.en.short_name": 1 }); +RecipeSchema.index({ "translations.en.translationStatus": 1 }); export const Recipe = mongoose.model("Recipe", RecipeSchema); diff --git a/src/routes/api/rezepte/translate/untranslated/+server.ts b/src/routes/api/rezepte/translate/untranslated/+server.ts new file mode 100644 index 00000000..c668621e --- /dev/null +++ b/src/routes/api/rezepte/translate/untranslated/+server.ts @@ -0,0 +1,48 @@ +import { json, type RequestHandler, error } from '@sveltejs/kit'; +import { Recipe } from '../../../../../models/Recipe'; +import { dbConnect } from '../../../../../utils/db'; + +export const GET: RequestHandler = async ({ locals }) => { + const session = await locals.auth(); + + if (!session?.user?.nickname) { + throw error(401, 'Anmeldung erforderlich'); + } + + if (!session.user.groups?.includes('rezepte_users')) { + throw error(403, 'Zugriff verweigert'); + } + + await dbConnect(); + + try { + // Find recipes without approved English translation + const untranslated = await Recipe.find({ + $or: [ + { 'translations.en': { $exists: false } }, + { 'translations.en.translationStatus': { $ne: 'approved' } } + ] + }, 'name short_name category icon description tags season dateModified translations.en.translationStatus') + .sort({ dateModified: 1 }) // Oldest first - highest priority + .lean(); + + // Transform to include translationStatus at top level for easier UI handling + const result = untranslated.map(recipe => ({ + _id: recipe._id, + name: recipe.name, + short_name: recipe.short_name, + category: recipe.category, + icon: recipe.icon, + description: recipe.description, + tags: recipe.tags || [], + season: recipe.season || [], + dateModified: recipe.dateModified, + translationStatus: recipe.translations?.en?.translationStatus || undefined + })); + + return json(JSON.parse(JSON.stringify(result))); + } catch (e) { + console.error('Error fetching untranslated recipes:', e); + throw error(500, 'Fehler beim Laden der unübersetzten Rezepte'); + } +}; diff --git a/src/routes/rezepte/+layout.server.ts b/src/routes/rezepte/+layout.server.ts new file mode 100644 index 00000000..0e4f90ac --- /dev/null +++ b/src/routes/rezepte/+layout.server.ts @@ -0,0 +1,9 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => { + const session = await locals.auth(); + + return { + session + }; +}; diff --git a/src/routes/rezepte/+layout.svelte b/src/routes/rezepte/+layout.svelte new file mode 100644 index 00000000..acf09e90 --- /dev/null +++ b/src/routes/rezepte/+layout.svelte @@ -0,0 +1,46 @@ + + +
+ {#snippet links()} + + {/snippet} + + {#snippet language_selector_mobile()} + + {/snippet} + + {#snippet language_selector_desktop()} + + {/snippet} + + {#snippet right_side()} + + {/snippet} + + {@render children()} +
diff --git a/src/routes/rezepte/untranslated/+page.server.ts b/src/routes/rezepte/untranslated/+page.server.ts new file mode 100644 index 00000000..e3b10f24 --- /dev/null +++ b/src/routes/rezepte/untranslated/+page.server.ts @@ -0,0 +1,41 @@ +import type { PageServerLoad } from "./$types"; +import { redirect, error } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ fetch, locals }) => { + const session = await locals.auth(); + + // Redirect to login if not authenticated + if (!session?.user?.nickname) { + throw redirect(302, '/login?callbackUrl=/rezepte/untranslated'); + } + + // 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, + error: 'Fehler beim Laden der unübersetzten Rezepte' + }; + } + + const untranslated = await res.json(); + + return { + untranslated, + session + }; + } catch (e) { + return { + untranslated: [], + session, + error: 'Fehler beim Laden der unübersetzten Rezepte' + }; + } +}; diff --git a/src/routes/rezepte/untranslated/+page.svelte b/src/routes/rezepte/untranslated/+page.svelte new file mode 100644 index 00000000..576c57b5 --- /dev/null +++ b/src/routes/rezepte/untranslated/+page.svelte @@ -0,0 +1,142 @@ + + + + + + Unübersetzte Rezepte - Bocken Rezepte + + + +

Unübersetzte Rezepte

+ +{#if data.error} +

{data.error}

+{:else if data.untranslated.length > 0} +

+ {stats.total} {stats.total === 1 ? 'Rezept benötigt' : 'Rezepte benötigen'} Übersetzung oder Überprüfung +

+ +
+

Statistik

+
+
+
{stats.noTranslation}
+
Keine Übersetzung
+
+
+
{stats.pending}
+
Freigabe ausstehend
+
+
+
{stats.needsUpdate}
+
Aktualisierung erforderlich
+
+
+
+ + + {#each data.untranslated as recipe} + + {/each} + +{:else} +
+

🎉 Alle Rezepte sind übersetzt!

+

+ Zurück zu den Rezepten +

+
+{/if}