refactor: unify recipe routes into [recipeLang] slug with full bilingual support

Consolidate /rezepte and /recipes routes into single [recipeLang] structure to eliminate code duplication. All pages now use conditional API routing and reactive labels based on language parameter.

- Merge duplicate route structures into /[recipeLang] with 404 for invalid slugs
- Add English API endpoints for search, favorites, tags, and categories
- Implement language dropdown in header with localStorage persistence
- Convert all pages to use Svelte 5 runes (, , )
- Add German-only redirects (301) for add/edit pages
- Make all view pages (list, detail, filters, search, favorites) fully bilingual
- Remove floating language switcher in favor of header dropdown
This commit is contained in:
2025-12-26 21:19:27 +01:00
parent 1f16d1c5c9
commit c2e4576c42
72 changed files with 417511 additions and 1097 deletions
@@ -0,0 +1,70 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { UserFavorites } from '../../../../../models/UserFavorites';
import { Recipe } from '../../../../../models/Recipe';
import { dbConnect } from '../../../../../utils/db';
import type { RecipeModelType } from '../../../../../types/types';
import { error } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
await dbConnect();
try {
const userFavorites = await UserFavorites.findOne({
username: session.user.nickname
}).lean();
if (!userFavorites?.favorites?.length) {
return json([]);
}
// Get recipes that are favorited AND have English translations
let recipes = await Recipe.find({
_id: { $in: userFavorites.favorites },
'translations.en': { $exists: true }
}).lean();
// Transform to English format
const englishRecipes = recipes.map(recipe => ({
_id: recipe._id,
short_name: recipe.translations.en.short_name,
name: recipe.translations.en.name,
category: recipe.translations.en.category,
icon: recipe.icon,
dateCreated: recipe.dateCreated,
dateModified: recipe.dateModified,
images: recipe.images?.map((img, idx) => ({
mediapath: img.mediapath,
alt: recipe.translations.en.images?.[idx]?.alt || img.alt,
caption: recipe.translations.en.images?.[idx]?.caption || img.caption,
})),
description: recipe.translations.en.description,
note: recipe.translations.en.note,
tags: recipe.translations.en.tags || [],
season: recipe.season,
baking: recipe.baking,
preparation: recipe.preparation,
fermentation: recipe.fermentation,
portions: recipe.portions,
cooking: recipe.cooking,
total_time: recipe.total_time,
ingredients: recipe.translations.en.ingredients || [],
instructions: recipe.translations.en.instructions || [],
preamble: recipe.translations.en.preamble,
addendum: recipe.translations.en.addendum,
germanShortName: recipe.short_name,
translationStatus: recipe.translations.en.translationStatus
}));
const result = JSON.parse(JSON.stringify(englishRecipes));
return json(result);
} catch (e) {
throw error(500, 'Failed to fetch favorite recipes');
}
};
@@ -0,0 +1,14 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { Recipe } from '../../../../../models/Recipe';
import { dbConnect } from '../../../../../utils/db';
export const GET: RequestHandler = async ({params}) => {
await dbConnect();
// Get distinct categories from English translations
const categories = await Recipe.distinct('translations.en.category', {
'translations.en': { $exists: true }
}).lean();
return json(JSON.parse(JSON.stringify(categories)));
};
@@ -0,0 +1,24 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { Recipe } from '../../../../../models/Recipe';
import { dbConnect } from '../../../../../utils/db';
export const GET: RequestHandler = async ({params}) => {
await dbConnect();
// Get all recipes with English translations
const recipes = await Recipe.find({
'translations.en': { $exists: true }
}, 'translations.en.tags').lean();
// Extract and flatten all unique tags
const tagsSet = new Set<string>();
recipes.forEach(recipe => {
if (recipe.translations?.en?.tags) {
recipe.translations.en.tags.forEach((tag: string) => tagsSet.add(tag));
}
});
const tags = Array.from(tagsSet).sort();
return json(JSON.parse(JSON.stringify(tags)));
};
+88
View File
@@ -0,0 +1,88 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import type { BriefRecipeType } from '../../../../types/types';
import { Recipe } from '../../../../models/Recipe';
import { dbConnect } from '../../../../utils/db';
export const GET: RequestHandler = async ({ url, locals }) => {
await dbConnect();
const query = url.searchParams.get('q')?.toLowerCase().trim() || '';
const category = url.searchParams.get('category');
const tag = url.searchParams.get('tag');
const icon = url.searchParams.get('icon');
const season = url.searchParams.get('season');
const favoritesOnly = url.searchParams.get('favorites') === 'true';
try {
// Build base query - only recipes with English translations
let dbQuery: any = {
'translations.en': { $exists: true }
};
// Apply filters based on context
if (category) {
dbQuery['translations.en.category'] = category;
}
if (tag) {
dbQuery['translations.en.tags'] = { $in: [tag] };
}
if (icon) {
dbQuery.icon = icon; // Icon is the same for both languages
}
if (season) {
const seasonNum = parseInt(season);
if (!isNaN(seasonNum)) {
dbQuery.season = { $in: [seasonNum] }; // Season is the same for both languages
}
}
// Get all recipes matching base filters
let recipes = await Recipe.find(dbQuery).lean();
// Handle favorites filter
if (favoritesOnly && locals.session?.user) {
const { UserFavorites } = await import('../../../../models/UserFavorites');
const userFavorites = await UserFavorites.findOne({ username: locals.session.user.username });
if (userFavorites && userFavorites.favorites) {
const favoriteIds = userFavorites.favorites;
recipes = recipes.filter(recipe => favoriteIds.some(id => id.toString() === recipe._id?.toString()));
} else {
recipes = [];
}
}
// Transform to English brief format
let briefRecipes: BriefRecipeType[] = recipes.map(recipe => ({
_id: recipe._id,
name: recipe.translations.en.name,
short_name: recipe.translations.en.short_name,
tags: recipe.translations.en.tags || [],
category: recipe.translations.en.category,
icon: recipe.icon,
description: recipe.translations.en.description,
season: recipe.season,
dateModified: recipe.dateModified,
germanShortName: recipe.short_name
}));
// Apply text search if query provided
if (query) {
const searchTerms = query.normalize('NFD').replace(/\p{Diacritic}/gu, "").split(" ");
briefRecipes = briefRecipes.filter(recipe => {
const searchString = `${recipe.name} ${recipe.description || ''} ${recipe.tags?.join(' ') || ''}`.toLowerCase()
.normalize('NFD').replace(/\p{Diacritic}/gu, "").replace(/&shy;|­/g, '');
return searchTerms.every(term => searchString.includes(term));
});
}
return json(JSON.parse(JSON.stringify(briefRecipes)));
} catch (error) {
return json({ error: 'Search failed' }, { status: 500 });
}
};