feat: add untranslated recipes page for recipe admins
All checks were successful
CI / update (push) Successful in 1m9s
All checks were successful
CI / update (push) Successful in 1m9s
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
This commit is contained in:
@@ -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}
|
||||
<div class="favorite-indicator">❤️</div>
|
||||
{/if}
|
||||
{#if translationStatus !== undefined}
|
||||
<div class="translation-badge {translationStatus || 'none'}">
|
||||
{#if translationStatus === 'pending'}
|
||||
Freigabe ausstehend
|
||||
{:else if translationStatus === 'needs_update'}
|
||||
Aktualisierung erforderlich
|
||||
{:else}
|
||||
Keine Übersetzung
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if icon_override || recipe.season.includes(current_month)}
|
||||
<a href="{routePrefix}/icon/{recipe.icon}" class=icon>{recipe.icon}</a>
|
||||
{/if}
|
||||
|
||||
@@ -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);
|
||||
|
||||
48
src/routes/api/rezepte/translate/untranslated/+server.ts
Normal file
48
src/routes/api/rezepte/translate/untranslated/+server.ts
Normal file
@@ -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');
|
||||
}
|
||||
};
|
||||
9
src/routes/rezepte/+layout.server.ts
Normal file
9
src/routes/rezepte/+layout.server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
const session = await locals.auth();
|
||||
|
||||
return {
|
||||
session
|
||||
};
|
||||
};
|
||||
46
src/routes/rezepte/+layout.svelte
Normal file
46
src/routes/rezepte/+layout.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
|
||||
let { data, children } = $props();
|
||||
let user = $derived(data.session?.user);
|
||||
|
||||
function isActive(path: string) {
|
||||
const currentPath = $page.url.pathname;
|
||||
return currentPath.startsWith(path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Header>
|
||||
{#snippet links()}
|
||||
<ul class="site_header">
|
||||
<li><a href="/rezepte" class:active={isActive('/rezepte') && $page.url.pathname === '/rezepte'}>Alle Rezepte</a></li>
|
||||
{#if user}
|
||||
<li><a href="/rezepte/favorites" class:active={isActive('/rezepte/favorites')}>Favoriten</a></li>
|
||||
{/if}
|
||||
<li><a href="/rezepte/season" class:active={isActive('/rezepte/season')}>Saison</a></li>
|
||||
<li><a href="/rezepte/category" class:active={isActive('/rezepte/category')}>Kategorie</a></li>
|
||||
<li><a href="/rezepte/icon" class:active={isActive('/rezepte/icon')}>Icon</a></li>
|
||||
<li><a href="/rezepte/tag" class:active={isActive('/rezepte/tag')}>Tags</a></li>
|
||||
{#if user?.groups?.includes('rezepte_users')}
|
||||
<li><a href="/rezepte/untranslated" class:active={isActive('/rezepte/untranslated')}>Unübersetzt</a></li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/snippet}
|
||||
|
||||
{#snippet language_selector_mobile()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
|
||||
{#snippet language_selector_desktop()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
|
||||
{#snippet right_side()}
|
||||
<UserHeader {user}></UserHeader>
|
||||
{/snippet}
|
||||
|
||||
{@render children()}
|
||||
</Header>
|
||||
41
src/routes/rezepte/untranslated/+page.server.ts
Normal file
41
src/routes/rezepte/untranslated/+page.server.ts
Normal file
@@ -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'
|
||||
};
|
||||
}
|
||||
};
|
||||
142
src/routes/rezepte/untranslated/+page.svelte
Normal file
142
src/routes/rezepte/untranslated/+page.svelte
Normal 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 }: { data: PageData } = $props();
|
||||
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="/rezepte"
|
||||
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="/rezepte">Zurück zu den Rezepten</a>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user