feat(zitare): complete i18n coverage for all pages

Extract ~40 hardcoded German strings into de.json/en.json locale files.
Covers favorites, lists, list detail, search, and settings pages
including toasts, modals, form labels, empty states, and aria labels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 16:18:00 +01:00
parent cf9cbebd34
commit 90e6135637
7 changed files with 209 additions and 72 deletions

View file

@ -43,13 +43,62 @@
"favorites": {
"title": "Favoriten",
"empty": "Noch keine Favoriten",
"emptyDescription": "Tippe auf das Herz-Symbol, um Zitate zu speichern"
"emptyDescription": "Tippe auf das Herz-Symbol, um Zitate zu speichern",
"loginPrompt": "Melde dich an, um Favoriten zu speichern",
"removeFromFavorites": "Aus Favoriten entfernen",
"copyQuote": "Zitat kopieren",
"share": "Teilen"
},
"lists": {
"title": "Meine Listen",
"create": "Neue Liste",
"empty": "Noch keine Listen",
"emptyDescription": "Erstelle Listen, um Zitate zu organisieren"
"emptyDescription": "Erstelle Listen, um Zitate zu organisieren",
"loginPrompt": "Melde dich an, um Listen zu erstellen",
"quoteCount": "{count} Zitate",
"createModal": {
"title": "Neue Liste erstellen",
"namePlaceholder": "z.B. Motivierende Zitate",
"descriptionPlaceholder": "Was macht diese Liste besonders?",
"submit": "Erstellen"
},
"nameLabel": "Name",
"descriptionLabel": "Beschreibung (optional)",
"confirmDelete": "Möchtest du diese Liste wirklich löschen?",
"detail": {
"notFound": "Liste nicht gefunden",
"notFoundDescription": "Diese Liste existiert nicht oder wurde gelöscht.",
"backToLists": "Zurück zu Listen",
"breadcrumb": "Listen",
"lastEdited": "Zuletzt bearbeitet: {date}",
"searchPlaceholder": "Zitate durchsuchen...",
"emptyTitle": "Keine Zitate in dieser Liste",
"emptyDescription": "Füge Zitate hinzu, um deine Sammlung zu starten",
"addQuotes": "Zitate hinzufügen",
"remove": "Entfernen",
"removeConfirm": "Zitat aus dieser Liste entfernen?",
"noSearchResults": "Keine Ergebnisse",
"noSearchResultsDescription": "Versuche es mit anderen Suchbegriffen",
"floatingResults": "{filtered} von {total} Zitaten",
"editModal": {
"title": "Liste bearbeiten",
"deleteList": "Liste löschen"
},
"addModal": {
"title": "Zitate hinzufügen",
"selected": "{count} ausgewählt",
"submit": "Hinzufügen ({count})"
},
"toast": {
"updated": "Liste aktualisiert!",
"updateError": "Fehler beim Aktualisieren",
"deleted": "Liste gelöscht",
"deleteError": "Fehler beim Löschen",
"quotesAdded": "{count} {count, plural, one {Zitat} other {Zitate}} hinzugefügt!",
"quoteRemoved": "Zitat entfernt",
"removeError": "Fehler beim Entfernen"
}
}
},
"search": {
"title": "Suche",
@ -59,7 +108,16 @@
"searching": "Suche...",
"create": "Erstellen",
"createList": "als Liste erstellen",
"createListDescription": "Neue Liste mit diesem Namen erstellen"
"createListDescription": "Neue Liste mit diesem Namen erstellen",
"minChars": "Bitte gib mindestens 2 Zeichen ein",
"hint": "Suche nach Zitaten, Autoren oder Themen"
},
"settings": {
"quoteLanguage": "Zitat-Sprache",
"quoteLanguageDescription": "Wähle die Sprache, in der die Zitate angezeigt werden sollen.",
"about": "Über Zitare",
"aboutDescription": "Zitare bietet dir täglich inspirierende Zitate von den größten Denkern der Geschichte. Speichere deine Favoriten und erstelle eigene Listen.",
"stats": "{quotes} Zitate · {categories} Kategorien · {languages} Sprachen"
},
"auth": {
"login": "Anmelden",
@ -77,6 +135,9 @@
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten"
"edit": "Bearbeiten",
"close": "Schließen",
"search": "Suchen",
"list": "Liste"
}
}

View file

@ -43,13 +43,62 @@
"favorites": {
"title": "Favorites",
"empty": "No favorites yet",
"emptyDescription": "Tap the heart icon to save quotes"
"emptyDescription": "Tap the heart icon to save quotes",
"loginPrompt": "Sign in to save favorites",
"removeFromFavorites": "Remove from favorites",
"copyQuote": "Copy quote",
"share": "Share"
},
"lists": {
"title": "My Lists",
"create": "New List",
"empty": "No lists yet",
"emptyDescription": "Create lists to organize quotes"
"emptyDescription": "Create lists to organize quotes",
"loginPrompt": "Sign in to create lists",
"quoteCount": "{count} quotes",
"createModal": {
"title": "Create new list",
"namePlaceholder": "e.g. Motivational Quotes",
"descriptionPlaceholder": "What makes this list special?",
"submit": "Create"
},
"nameLabel": "Name",
"descriptionLabel": "Description (optional)",
"confirmDelete": "Do you really want to delete this list?",
"detail": {
"notFound": "List not found",
"notFoundDescription": "This list does not exist or has been deleted.",
"backToLists": "Back to lists",
"breadcrumb": "Lists",
"lastEdited": "Last edited: {date}",
"searchPlaceholder": "Search quotes...",
"emptyTitle": "No quotes in this list",
"emptyDescription": "Add quotes to start your collection",
"addQuotes": "Add quotes",
"remove": "Remove",
"removeConfirm": "Remove quote from this list?",
"noSearchResults": "No results",
"noSearchResultsDescription": "Try different search terms",
"floatingResults": "{filtered} of {total} quotes",
"editModal": {
"title": "Edit list",
"deleteList": "Delete list"
},
"addModal": {
"title": "Add quotes",
"selected": "{count} selected",
"submit": "Add ({count})"
},
"toast": {
"updated": "List updated!",
"updateError": "Error updating list",
"deleted": "List deleted",
"deleteError": "Error deleting list",
"quotesAdded": "{count} {count, plural, one {quote} other {quotes}} added!",
"quoteRemoved": "Quote removed",
"removeError": "Error removing quote"
}
}
},
"search": {
"title": "Search",
@ -59,7 +108,16 @@
"searching": "Searching...",
"create": "Create",
"createList": "create as list",
"createListDescription": "Create a new list with this name"
"createListDescription": "Create a new list with this name",
"minChars": "Please enter at least 2 characters",
"hint": "Search for quotes, authors, or topics"
},
"settings": {
"quoteLanguage": "Quote language",
"quoteLanguageDescription": "Choose the language in which quotes are displayed.",
"about": "About Zitare",
"aboutDescription": "Zitare offers you daily inspiring quotes from the greatest thinkers in history. Save your favorites and create your own lists.",
"stats": "{quotes} quotes · {categories} categories · {languages} languages"
},
"auth": {
"login": "Sign In",
@ -77,6 +135,9 @@
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit"
"edit": "Edit",
"close": "Close",
"search": "Search",
"list": "List"
}
}

View file

@ -36,14 +36,14 @@
return [
{
id: 'remove-favorite',
label: $_('favorites.removeFromFavorites', { default: 'Aus Favoriten entfernen' }),
label: $_('favorites.removeFromFavorites'),
variant: 'danger',
action: () => favoritesStore.toggle(quote.id),
},
{ id: 'divider-1', label: '', type: 'divider' },
{
id: 'copy',
label: $_('common.copyQuote', { default: 'Zitat kopieren' }),
label: $_('favorites.copyQuote'),
action: () => {
const text = getQuoteText(quote);
navigator.clipboard.writeText(`"${text}" — ${quote.author}`);
@ -51,7 +51,7 @@
},
{
id: 'share',
label: $_('common.share', { default: 'Teilen' }),
label: $_('favorites.share'),
action: async () => {
const text = `"${getQuoteText(quote)}" — ${quote.author}`;
if (navigator.share) {
@ -92,7 +92,7 @@
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<p class="text-foreground-secondary mb-4">Melde dich an, um Favoriten zu speichern</p>
<p class="text-foreground-secondary mb-4">{$_('favorites.loginPrompt')}</p>
<button
onclick={() => goto('/login')}
class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"

View file

@ -90,7 +90,7 @@
}
async function deleteList(listId: string) {
if (!confirm('Möchtest du diese Liste wirklich löschen?')) return;
if (!confirm($_('lists.confirmDelete'))) return;
const token = await authStore.getValidToken();
if (!token) return;
@ -155,7 +155,7 @@
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<p class="text-foreground-secondary mb-4">Melde dich an, um Listen zu erstellen</p>
<p class="text-foreground-secondary mb-4">{$_('lists.loginPrompt')}</p>
<button
onclick={() => goto('/login')}
class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
@ -205,7 +205,7 @@
<p class="text-foreground-secondary mt-1">{list.description}</p>
{/if}
<p class="text-sm text-foreground-muted mt-2">
{list.quoteIds.length} Zitate
{$_('lists.quoteCount', { values: { count: list.quoteIds.length } })}
</p>
</div>
<button
@ -237,7 +237,7 @@
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-surface-elevated rounded-2xl w-full max-w-md shadow-xl">
<div class="flex items-center justify-between p-6 border-b border-border">
<h3 class="text-xl font-semibold text-foreground">Neue Liste erstellen</h3>
<h3 class="text-xl font-semibold text-foreground">{$_('lists.createModal.title')}</h3>
<button
onclick={() => (showCreateModal = false)}
class="p-2 text-foreground-secondary hover:text-foreground transition-colors"
@ -254,21 +254,23 @@
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-foreground mb-2">Name *</label>
<label class="block text-sm font-medium text-foreground mb-2"
>{$_('lists.nameLabel')} *</label
>
<input
type="text"
bind:value={newListName}
placeholder="z.B. Motivierende Zitate"
placeholder={$_('lists.createModal.namePlaceholder')}
class="w-full p-3 rounded-lg bg-surface border border-border text-foreground focus:outline-none focus:border-primary"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-2"
>Beschreibung (optional)</label
>{$_('lists.descriptionLabel')}</label
>
<textarea
bind:value={newListDescription}
placeholder="Was macht diese Liste besonders?"
placeholder={$_('lists.createModal.descriptionPlaceholder')}
rows="3"
class="w-full p-3 rounded-lg bg-surface border border-border text-foreground focus:outline-none focus:border-primary resize-none"
></textarea>
@ -286,7 +288,7 @@
disabled={!newListName.trim()}
class="px-6 py-2 bg-primary text-white rounded-lg font-medium hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Erstellen
{$_('lists.createModal.submit')}
</button>
</div>
</div>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { _, locale } from 'svelte-i18n';
import { listsStore, type QuoteList } from '$lib/stores/lists.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { quotesStore } from '$lib/stores/quotes.svelte';
@ -40,7 +40,7 @@
isLoading = false;
if (!list) {
toast.error('Liste nicht gefunden');
toast.error($_('lists.detail.notFound'));
}
}
@ -90,22 +90,22 @@
});
if (updated) {
list = updated;
toast.success('Liste aktualisiert!');
toast.success($_('lists.detail.toast.updated'));
closeEditModal();
} else {
toast.error('Fehler beim Aktualisieren');
toast.error($_('lists.detail.toast.updateError'));
}
}
}
async function handleDeleteList() {
if (list && confirm('Möchtest du diese Liste wirklich löschen?')) {
if (list && confirm($_('lists.confirmDelete'))) {
const success = await listsStore.deleteList(list.id);
if (success) {
toast.info('Liste gelöscht');
toast.info($_('lists.detail.toast.deleted'));
goto('/lists');
} else {
toast.error('Fehler beim Löschen');
toast.error($_('lists.detail.toast.deleteError'));
}
}
}
@ -140,27 +140,27 @@
if (successCount > 0) {
// Reload list to get updated quote IDs
list = await listsStore.getList(list.id);
toast.success(`${successCount} ${successCount === 1 ? 'Zitat' : 'Zitate'} hinzugefügt!`);
toast.success($_('lists.detail.toast.quotesAdded', { values: { count: successCount } }));
}
closeAddQuotesModal();
}
}
async function handleRemoveQuote(quoteId: string) {
if (list && confirm('Zitat aus dieser Liste entfernen?')) {
if (list && confirm($_('lists.detail.removeConfirm'))) {
const success = await listsStore.removeQuoteFromList(list.id, quoteId);
if (success) {
// Reload list to get updated quote IDs
list = await listsStore.getList(list.id);
toast.info('Zitat entfernt');
toast.info($_('lists.detail.toast.quoteRemoved'));
} else {
toast.error('Fehler beim Entfernen');
toast.error($_('lists.detail.toast.removeError'));
}
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
return new Date(dateStr).toLocaleDateString($locale || 'de', {
year: 'numeric',
month: 'long',
day: 'numeric',
@ -169,7 +169,7 @@
</script>
<svelte:head>
<title>{list?.name || 'Liste'} - Zitare</title>
<title>{list?.name || $_('common.list')} - Zitare</title>
</svelte:head>
{#if isLoading}
@ -179,16 +179,16 @@
</div>
{:else if !list}
<div class="error-state">
<h2>Liste nicht gefunden</h2>
<p>Diese Liste existiert nicht oder wurde gelöscht.</p>
<a href="/lists" class="cta-button">Zurück zu Listen</a>
<h2>{$_('lists.detail.notFound')}</h2>
<p>{$_('lists.detail.notFoundDescription')}</p>
<a href="/lists" class="cta-button">{$_('lists.detail.backToLists')}</a>
</div>
{:else}
<div class="list-detail-page">
<!-- Header -->
<div class="header-container">
<div class="breadcrumb">
<a href="/lists">Listen</a>
<a href="/lists">{$_('lists.detail.breadcrumb')}</a>
<span class="separator">/</span>
<span>{list.name}</span>
</div>
@ -200,15 +200,19 @@
<p class="description">{list.description}</p>
{/if}
<div class="meta">
<span>{listQuotes.length} Zitate</span>
<span>{$_('lists.quoteCount', { values: { count: listQuotes.length } })}</span>
<span class="separator"></span>
<span>Zuletzt bearbeitet: {formatDate(list.updatedAt)}</span>
<span
>{$_('lists.detail.lastEdited', {
values: { date: formatDate(list.updatedAt) },
})}</span
>
</div>
</div>
<div class="header-actions">
{#if listQuotes.length > 0}
<button class="icon-btn" onclick={toggleSearch} aria-label="Suchen">
<button class="icon-btn" onclick={toggleSearch} aria-label={$_('common.search')}>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{#if isSearchOpen}
<path
@ -229,7 +233,11 @@
</button>
{/if}
<button class="icon-btn" onclick={openEditModal} aria-label="Liste bearbeiten">
<button
class="icon-btn"
onclick={openEditModal}
aria-label={$_('lists.detail.editModal.title')}
>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
@ -243,7 +251,7 @@
<button
class="icon-btn add-btn"
onclick={openAddQuotesModal}
aria-label="Zitate hinzufügen"
aria-label={$_('lists.detail.addQuotes')}
>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
@ -261,7 +269,7 @@
<div class="search-bar">
<input
type="text"
placeholder="Zitate durchsuchen..."
placeholder={$_('lists.detail.searchPlaceholder')}
bind:value={searchTerm}
class="search"
/>
@ -290,8 +298,8 @@
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</div>
<h3>Keine Zitate in dieser Liste</h3>
<p>Füge Zitate hinzu, um deine Sammlung zu starten</p>
<h3>{$_('lists.detail.emptyTitle')}</h3>
<p>{$_('lists.detail.emptyDescription')}</p>
<button class="cta-button" onclick={openAddQuotesModal}>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
@ -301,7 +309,7 @@
d="M12 4v16m8-8H4"
/>
</svg>
Zitate hinzufügen
{$_('lists.detail.addQuotes')}
</button>
</div>
{:else if filteredQuotes.length === 0}
@ -320,8 +328,8 @@
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<h3>Keine Ergebnisse</h3>
<p>Versuche es mit anderen Suchbegriffen</p>
<h3>{$_('lists.detail.noSearchResults')}</h3>
<p>{$_('lists.detail.noSearchResultsDescription')}</p>
</div>
{:else}
<div class="quotes-grid">
@ -331,7 +339,7 @@
<button
class="remove-btn"
onclick={() => handleRemoveQuote(quote.id)}
aria-label="Aus Liste entfernen"
aria-label={$_('lists.detail.remove')}
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
@ -341,7 +349,7 @@
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Entfernen
{$_('lists.detail.remove')}
</button>
</div>
{/each}
@ -350,7 +358,9 @@
{#if isSearchOpen && filteredQuotes.length > 0}
<div class="floating-results">
{filteredQuotes.length} von {listQuotes.length} Zitaten
{$_('lists.detail.floatingResults', {
values: { filtered: filteredQuotes.length, total: listQuotes.length },
})}
</div>
{/if}
</div>
@ -361,8 +371,8 @@
<div class="modal-overlay" onclick={closeEditModal} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div class="modal-header">
<h3>Liste bearbeiten</h3>
<button class="close-btn" onclick={closeEditModal} aria-label="Schließen">
<h3>{$_('lists.detail.editModal.title')}</h3>
<button class="close-btn" onclick={closeEditModal} aria-label={$_('common.close')}>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
@ -376,7 +386,7 @@
<div class="modal-body">
<div class="form-group">
<label for="edit-name">Name *</label>
<label for="edit-name">{$_('lists.nameLabel')} *</label>
<input
id="edit-name"
type="text"
@ -387,7 +397,7 @@
</div>
<div class="form-group">
<label for="edit-description">Beschreibung (optional)</label>
<label for="edit-description">{$_('lists.descriptionLabel')}</label>
<textarea
id="edit-description"
bind:value={editDescription}
@ -406,14 +416,14 @@
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Liste löschen
{$_('lists.detail.editModal.deleteList')}
</button>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick={closeEditModal}> Abbrechen </button>
<button class="btn btn-secondary" onclick={closeEditModal}>{$_('common.cancel')}</button>
<button class="btn btn-primary" onclick={handleUpdateList} disabled={!editName.trim()}>
Speichern
{$_('common.save')}
</button>
</div>
</div>
@ -430,8 +440,8 @@
aria-modal="true"
>
<div class="modal-header">
<h3>Zitate hinzufügen</h3>
<button class="close-btn" onclick={closeAddQuotesModal} aria-label="Schließen">
<h3>{$_('lists.detail.addModal.title')}</h3>
<button class="close-btn" onclick={closeAddQuotesModal} aria-label={$_('common.close')}>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
@ -461,16 +471,18 @@
<div class="modal-footer">
<div class="selected-count">
{selectedQuoteIds.size} ausgewählt
{$_('lists.detail.addModal.selected', { values: { count: selectedQuoteIds.size } })}
</div>
<div class="footer-actions">
<button class="btn btn-secondary" onclick={closeAddQuotesModal}> Abbrechen </button>
<button class="btn btn-secondary" onclick={closeAddQuotesModal}
>{$_('common.cancel')}</button
>
<button
class="btn btn-primary"
onclick={handleAddQuotes}
disabled={selectedQuoteIds.size === 0}
>
Hinzufügen ({selectedQuoteIds.size})
{$_('lists.detail.addModal.submit', { values: { count: selectedQuoteIds.size } })}
</button>
</div>
</div>

View file

@ -98,7 +98,7 @@
</div>
{/if}
{:else if searchTerm.length > 0}
<p class="text-center text-foreground-muted py-8">Bitte gib mindestens 2 Zeichen ein</p>
<p class="text-center text-foreground-muted py-8">{$_('search.minChars')}</p>
{:else}
<div class="text-center py-12">
<svg
@ -111,7 +111,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m21 21-4.35-4.35"
></path>
</svg>
<p class="text-foreground-secondary">Suche nach Zitaten, Autoren oder Themen</p>
<p class="text-foreground-secondary">{$_('search.hint')}</p>
</div>
{/if}
</div>

View file

@ -33,9 +33,9 @@
<div class="space-y-6">
<!-- Quote Language -->
<div class="bg-surface-elevated rounded-2xl p-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Zitat-Sprache</h2>
<h2 class="text-lg font-semibold text-foreground mb-4">{$_('settings.quoteLanguage')}</h2>
<p class="text-foreground-secondary text-sm mb-4">
Wähle die Sprache, in der die Zitate angezeigt werden sollen.
{$_('settings.quoteLanguageDescription')}
</p>
<select
value={quotesStore.language}
@ -50,13 +50,14 @@
<!-- About -->
<div class="bg-surface-elevated rounded-2xl p-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Über Zitare</h2>
<h2 class="text-lg font-semibold text-foreground mb-4">{$_('settings.about')}</h2>
<p class="text-foreground-secondary text-sm">
Zitare bietet dir täglich inspirierende Zitate von den größten Denkern der Geschichte.
Speichere deine Favoriten und erstelle eigene Listen.
{$_('settings.aboutDescription')}
</p>
<p class="text-foreground-muted text-sm mt-4">
{quotesStore.totalCount} Zitate · 10 Kategorien · 6 Sprachen
{$_('settings.stats', {
values: { quotes: quotesStore.totalCount, categories: 10, languages: 6 },
})}
</p>
</div>
</div>