i18n(inventory+questions+invitations): translate 3 routes via $_()

- inventory/items/[id]/+page: route through inventory.detail.* +
  dynamic inventory.status.<id>; drops local statusLabels constant
  (re-uses existing inventory.status.* keys). Fixes pre-existing
  typos endgultig/loschen/Zuruck/hinzufugen via proper translations.
- questions/+page: route through questions.home.* + dynamic
  questions.home.depth_<id>; locale-aware date formatting via
  get(locale) instead of hardcoded de-DE; drops unused
  ResearchDepth import + depthLabels const.
- accept-invitation/+page: route through invitations.accept.*
  (new namespace); space-type label still uses SPACE_TYPE_LABELS
  from shared-branding (only de/en available — locale-prefix gate).

Baseline 920 → 899 (-21).
This commit is contained in:
Till JS 2026-04-27 18:29:17 +02:00
parent ef3243a68a
commit 7339fba3aa
5 changed files with 95 additions and 80 deletions

View file

@ -49,13 +49,6 @@
let newNote = $state('');
const statuses: ItemStatus[] = ['owned', 'lent', 'stored', 'for_sale', 'disposed'];
const statusLabels: Record<ItemStatus, string> = {
owned: 'Besitzt',
lent: 'Verliehen',
stored: 'Eingelagert',
for_sale: 'Zu verkaufen',
disposed: 'Entsorgt',
};
function startEditing() {
if (!item) return;
@ -90,7 +83,7 @@
}
async function deleteItem() {
if (!item || !confirm('Item endgultig loschen?')) return;
if (!item || !confirm($_('inventory.detail.confirm_delete'))) return;
await itemsStore.delete(item.id);
goto(collection ? `/inventory/collections/${collection.id}` : '/inventory');
}
@ -100,14 +93,22 @@
</script>
<svelte:head>
<title>{item?.name || 'Item'} - Inventar - Mana</title>
<title
>{$_('inventory.detail.page_title_html', {
values: { name: item?.name || $_('inventory.detail.page_title_fallback') },
})}</title
>
</svelte:head>
<RoutePage appId="inventory" backHref="/inventory" title="Objekt">
<RoutePage appId="inventory" backHref="/inventory" title={$_('inventory.detail.route_title')}>
{#if !item}
<div class="text-center py-16">
<p class="text-[hsl(var(--color-muted-foreground))]">Item nicht gefunden</p>
<a href="/inventory" class="mt-4 text-[hsl(var(--color-primary))]">Zuruck</a>
<p class="text-[hsl(var(--color-muted-foreground))]">
{$_('inventory.detail.empty_not_found')}
</p>
<a href="/inventory" class="mt-4 text-[hsl(var(--color-primary))]"
>{$_('inventory.detail.empty_back')}</a
>
</div>
{:else}
<div class="mx-auto max-w-2xl space-y-6">
@ -154,7 +155,7 @@
<button
onclick={deleteItem}
class="rounded-lg border border-red-300 px-3 py-1.5 text-sm text-red-500 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-900/20"
>Loschen</button
>{$_('inventory.detail.action_delete')}</button
>
{/if}
</div>
@ -165,10 +166,15 @@
<div
class="space-y-4 rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-5"
>
<input type="text" bind:value={editName} placeholder="Name" class={inputClass} />
<input
type="text"
bind:value={editName}
placeholder={$_('inventory.detail.placeholder_name')}
class={inputClass}
/>
<textarea
bind:value={editDescription}
placeholder="Beschreibung"
placeholder={$_('inventory.detail.placeholder_description')}
rows="2"
class={inputClass}
></textarea>
@ -178,17 +184,17 @@
<label
for="inventory-status"
class="mb-1 block text-xs font-medium text-[hsl(var(--color-muted-foreground))]"
>Status</label
>{$_('inventory.detail.label_status')}</label
>
<select id="inventory-status" bind:value={editStatus} class={inputClass}>
{#each statuses as s}<option value={s}>{statusLabels[s]}</option>{/each}
{#each statuses as s}<option value={s}>{$_('inventory.status.' + s)}</option>{/each}
</select>
</div>
<div>
<label
for="inventory-quantity"
class="mb-1 block text-xs font-medium text-[hsl(var(--color-muted-foreground))]"
>Menge</label
>{$_('inventory.detail.label_quantity')}</label
>
<input
id="inventory-quantity"
@ -203,10 +209,10 @@
<label
for="inventory-location"
class="mb-1 block text-xs font-medium text-[hsl(var(--color-muted-foreground))]"
>Standort</label
>{$_('inventory.detail.label_location')}</label
>
<select id="inventory-location" bind:value={editLocationId} class={inputClass}>
<option value={undefined}>-- Kein Standort --</option>
<option value={undefined}>{$_('inventory.detail.option_no_location')}</option>
{#each locationsCtx.value as loc}
<option value={loc.id}>{loc.path ? `${loc.path}/` : ''}{loc.name}</option>
{/each}
@ -218,10 +224,10 @@
<label
for="inventory-category"
class="mb-1 block text-xs font-medium text-[hsl(var(--color-muted-foreground))]"
>Kategorie</label
>{$_('inventory.detail.label_category')}</label
>
<select id="inventory-category" bind:value={editCategoryId} class={inputClass}>
<option value={undefined}>-- Keine Kategorie --</option>
<option value={undefined}>{$_('inventory.detail.option_no_category')}</option>
{#each categoriesCtx.value as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
@ -233,7 +239,7 @@
{#if collection}
<div>
<h3 class="mb-2 text-sm font-semibold text-[hsl(var(--color-foreground))]">
Eigene Felder
{$_('inventory.detail.section_custom_fields')}
</h3>
<div class="grid gap-3 sm:grid-cols-2">
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
@ -294,7 +300,7 @@
class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--color-foreground))]">
Details
{$_('inventory.detail.section_details')}
</h3>
<div class="grid gap-2 sm:grid-cols-2">
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
@ -314,7 +320,7 @@
class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--color-foreground))]">
Notizen ({item.notes.length})
{$_('inventory.detail.section_notes_count', { values: { n: item.notes.length } })}
</h3>
<div class="space-y-2">
{#each item.notes as note (note.id)}
@ -339,7 +345,7 @@
<input
type="text"
bind:value={newNote}
placeholder="Notiz hinzufugen..."
placeholder={$_('inventory.detail.placeholder_new_note')}
class="flex-1 rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-input))] px-3 py-2 text-sm text-[hsl(var(--color-foreground))]"
onkeydown={(e) => e.key === 'Enter' && addNote()}
/>

View file

@ -6,9 +6,11 @@
filterByStatus,
searchQuestions,
} from '$lib/modules/questions/queries';
import type { QuestionStatus, ResearchDepth } from '$lib/modules/questions/types';
import type { QuestionStatus } from '$lib/modules/questions/types';
import { MagnifyingGlass, Clock, CheckCircle, CircleNotch, Archive } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell';
import { _, locale } from 'svelte-i18n';
import { get } from 'svelte/store';
const allQuestions = useAllQuestions();
const allCollections = useAllCollections();
@ -38,23 +40,17 @@
archived: { icon: Archive, color: 'text-muted-foreground' },
};
const depthLabels: Record<ResearchDepth, string> = {
quick: 'Quick',
standard: 'Standard',
deep: 'Deep',
};
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Heute';
if (days === 1) return 'Gestern';
if (days < 7) return `Vor ${days} Tagen`;
if (days === 0) return $_('questions.home.date_today');
if (days === 1) return $_('questions.home.date_yesterday');
if (days < 7) return $_('questions.home.date_days_ago', { values: { n: days } });
return date.toLocaleDateString('de-DE', {
return date.toLocaleDateString(get(locale) ?? 'de', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
@ -63,7 +59,7 @@
</script>
<svelte:head>
<title>Fragen - Mana</title>
<title>{$_('questions.home.page_title_html')}</title>
</svelte:head>
<RoutePage appId="questions">
@ -72,10 +68,12 @@
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--color-foreground))]">
{selectedCollection ? selectedCollection.name : 'Alle Fragen'}
{selectedCollection ? selectedCollection.name : $_('questions.home.heading_all')}
</h1>
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">
{filteredQuestions.length} Frage{filteredQuestions.length !== 1 ? 'n' : ''}
{filteredQuestions.length === 1
? $_('questions.home.count_one', { values: { n: filteredQuestions.length } })
: $_('questions.home.count_other', { values: { n: filteredQuestions.length } })}
</p>
</div>
<div class="flex gap-2">
@ -83,13 +81,13 @@
href="/questions/collections"
class="rounded-lg border border-[hsl(var(--color-border))] px-4 py-2 text-sm font-medium text-[hsl(var(--color-foreground))] transition-colors hover:bg-[hsl(var(--color-muted))]"
>
Sammlungen
{$_('questions.home.action_collections')}
</a>
<a
href="/questions/new"
class="rounded-lg bg-[hsl(var(--color-primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--color-primary-foreground))] transition-colors hover:opacity-90"
>
Neue Frage
{$_('questions.home.action_new')}
</a>
</div>
</div>
@ -103,7 +101,7 @@
<input
type="text"
bind:value={searchQuery}
placeholder="Fragen durchsuchen..."
placeholder={$_('questions.home.placeholder_search')}
class="w-full rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-input))] py-2 pl-10 pr-4 text-sm text-[hsl(var(--color-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--color-primary))]"
/>
</div>
@ -112,11 +110,11 @@
bind:value={statusFilter}
class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-input))] px-3 py-2 text-sm text-[hsl(var(--color-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--color-primary))]"
>
<option value="">Alle Status</option>
<option value="open">Offen</option>
<option value="researching">Recherche</option>
<option value="answered">Beantwortet</option>
<option value="archived">Archiviert</option>
<option value="">{$_('questions.home.filter_all_status')}</option>
<option value="open">{$_('questions.home.filter_open')}</option>
<option value="researching">{$_('questions.home.filter_researching')}</option>
<option value="answered">{$_('questions.home.filter_answered')}</option>
<option value="archived">{$_('questions.home.filter_archived')}</option>
</select>
{#if collections.length > 0}
@ -124,7 +122,7 @@
bind:value={selectedCollectionId}
class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-input))] px-3 py-2 text-sm text-[hsl(var(--color-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--color-primary))]"
>
<option value={null}>Alle Sammlungen</option>
<option value={null}>{$_('questions.home.filter_all_collections')}</option>
{#each collections as collection}
<option value={collection.id}>{collection.name}</option>
{/each}
@ -138,15 +136,17 @@
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--color-border))] py-16"
>
<span class="mb-4 text-5xl">🔍</span>
<h2 class="mb-2 text-lg font-semibold text-[hsl(var(--color-foreground))]">Keine Fragen</h2>
<h2 class="mb-2 text-lg font-semibold text-[hsl(var(--color-foreground))]">
{$_('questions.home.empty_title')}
</h2>
<p class="mb-6 text-sm text-[hsl(var(--color-muted-foreground))]">
Stelle deine erste Frage und lass die KI recherchieren.
{$_('questions.home.empty_hint')}
</p>
<a
href="/questions/new"
class="rounded-lg bg-[hsl(var(--color-primary))] px-6 py-2.5 text-sm font-medium text-[hsl(var(--color-primary-foreground))]"
>
Neue Frage
{$_('questions.home.empty_action')}
</a>
</div>
{:else}
@ -200,7 +200,7 @@
<span
class="rounded-full bg-[hsl(var(--color-muted))] px-2 py-0.5 text-xs text-[hsl(var(--color-muted-foreground))]"
>
{depthLabels[question.researchDepth]}
{$_('questions.home.depth_' + question.researchDepth)}
</span>
<span class="text-xs text-[hsl(var(--color-muted-foreground))]">

View file

@ -17,6 +17,8 @@
import { SPACE_TYPE_LABELS } from '@mana/shared-branding';
import { isSpaceType, type SpaceType } from '@mana/shared-types';
import { loadActiveSpace, authFetch, writeActiveSpaceHint } from '$lib/data/scope';
import { _, locale } from 'svelte-i18n';
import { get } from 'svelte/store';
interface InvitationPayload {
id: string;
@ -45,7 +47,7 @@
async function loadInvitation() {
if (!invitationId) {
loadError = 'Kein Einladungs-Code in der URL';
loadError = $_('invitations.accept.error_no_invitation_id');
loading = false;
return;
}
@ -56,7 +58,9 @@
`/api/auth/organization/get-invitation?id=${encodeURIComponent(invitationId)}`
);
if (!res.ok) {
throw new Error(`Einladung nicht gefunden (${res.status})`);
throw new Error(
$_('invitations.accept.error_not_found', { values: { status: res.status } })
);
}
invitation = (await res.json()) as InvitationPayload;
} catch (err) {
@ -124,44 +128,50 @@
<div class="page">
<div class="card">
{#if loading}
<p class="state">Lade Einladung …</p>
<p class="state">{$_('invitations.accept.loading')}</p>
{:else if loadError}
<h1>Einladung nicht abrufbar</h1>
<h1>{$_('invitations.accept.unavailable_title')}</h1>
<p class="error">{loadError}</p>
<p class="hint">Der Link ist möglicherweise abgelaufen oder schon benutzt.</p>
<p class="hint">{$_('invitations.accept.unavailable_hint')}</p>
{:else if invitation}
{#if invitation.status === 'accepted'}
<h1>Schon angenommen</h1>
<p class="hint">Diese Einladung ist bereits angenommen worden.</p>
<a href="/" class="btn primary">Zur App</a>
<h1>{$_('invitations.accept.already_accepted_title')}</h1>
<p class="hint">{$_('invitations.accept.already_accepted_hint')}</p>
<a href="/" class="btn primary">{$_('invitations.accept.to_app')}</a>
{:else if invitation.status === 'rejected' || invitation.status === 'canceled'}
<h1>Einladung abgelaufen</h1>
<p class="hint">Diese Einladung ist nicht mehr gültig.</p>
<h1>{$_('invitations.accept.expired_title')}</h1>
<p class="hint">{$_('invitations.accept.expired_hint')}</p>
{:else}
<p class="eyebrow">Einladung</p>
<p class="eyebrow">{$_('invitations.accept.eyebrow')}</p>
<h1>
{invitation.inviterEmail ?? 'Jemand'} lädt dich in
<strong>{invitation.organizationName ?? 'einen Space'}</strong> ein
{@html $_('invitations.accept.heading_invite_html', {
values: {
inviter: invitation.inviterEmail ?? $_('invitations.accept.inviter_fallback'),
space: invitation.organizationName ?? $_('invitations.accept.space_fallback'),
},
})}
</h1>
<p class="subtitle">
<span class="type-chip" data-type={spaceType}>{SPACE_TYPE_LABELS.de[spaceType]}</span>
<span>Rolle: {invitation.role}</span>
</p>
<p class="hint">
Nach Annahme kannst du in diesem Space mitarbeiten — sehen, was andere schreiben, und
selbst Einträge anlegen. Deine persönlichen Daten bleiben in deinem Personal-Space,
getrennt.
<span class="type-chip" data-type={spaceType}
>{(get(locale) ?? 'de').startsWith('de')
? SPACE_TYPE_LABELS.de[spaceType]
: SPACE_TYPE_LABELS.en[spaceType]}</span
>
<span>{$_('invitations.accept.role_label', { values: { role: invitation.role } })}</span>
</p>
<p class="hint">{$_('invitations.accept.explainer')}</p>
{#if actionError}<p class="error">{actionError}</p>{/if}
<div class="actions">
<button class="btn secondary" onclick={decline} disabled={submitting}>Ablehnen</button>
<button class="btn secondary" onclick={decline} disabled={submitting}
>{$_('invitations.accept.action_decline')}</button
>
<button class="btn primary" onclick={accept} disabled={submitting}>
{#if submitting}
Bearbeite …
{$_('invitations.accept.action_processing')}
{:else if !authStore.isAuthenticated}
Einloggen & annehmen
{$_('invitations.accept.action_login_accept')}
{:else}
Annehmen
{$_('invitations.accept.action_accept')}
{/if}
</button>
</div>

View file

@ -229,7 +229,6 @@
"apps/mana/apps/web/src/routes/(app)/inventory/collections/[id]/+page.svelte": 5,
"apps/mana/apps/web/src/routes/(app)/inventory/collections/[id]/edit/+page.svelte": 2,
"apps/mana/apps/web/src/routes/(app)/inventory/collections/new/+page.svelte": 1,
"apps/mana/apps/web/src/routes/(app)/inventory/items/[id]/+page.svelte": 7,
"apps/mana/apps/web/src/routes/(app)/inventory/locations/+page.svelte": 2,
"apps/mana/apps/web/src/routes/(app)/inventory/search/+page.svelte": 1,
"apps/mana/apps/web/src/routes/(app)/library/entry/[id]/+page.svelte": 1,
@ -278,7 +277,6 @@
"apps/mana/apps/web/src/routes/(app)/presi/present/[id]/+page.svelte": 2,
"apps/mana/apps/web/src/routes/(app)/profile/my-wishes/+page.svelte": 1,
"apps/mana/apps/web/src/routes/(app)/questions/[id]/+page.svelte": 3,
"apps/mana/apps/web/src/routes/(app)/questions/+page.svelte": 7,
"apps/mana/apps/web/src/routes/(app)/questions/collections/+page.svelte": 1,
"apps/mana/apps/web/src/routes/(app)/questions/new/+page.svelte": 6,
"apps/mana/apps/web/src/routes/(app)/skilltree/+page.svelte": 4,
@ -300,7 +298,6 @@
"apps/mana/apps/web/src/routes/(app)/wetter/+page.svelte": 2,
"apps/mana/apps/web/src/routes/(auth)/reset-password/+page.svelte": 1,
"apps/mana/apps/web/src/routes/+error.svelte": 1,
"apps/mana/apps/web/src/routes/accept-invitation/+page.svelte": 7,
"apps/mana/apps/web/src/routes/auth/callback/+page.svelte": 3,
"apps/mana/apps/web/src/routes/auth/reset-password/+page.svelte": 1,
"apps/mana/apps/web/src/routes/community/+layout.svelte": 5,

View file

@ -52,7 +52,9 @@
"apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/edit/+page.svelte": 26,
"apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/map/+page.svelte": 6,
"apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte": 6,
"apps/mana/apps/web/src/routes/(app)/inventory/items/[id]/+page.svelte": 1,
"apps/mana/apps/web/src/routes/(app)/invoices/[id]/edit/+page.svelte": 1,
"apps/mana/apps/web/src/routes/(app)/questions/+page.svelte": 1,
"apps/mana/apps/web/src/routes/(app)/quotes/+page.svelte": 3,
"apps/mana/apps/web/src/routes/(app)/quotes/categories/+page.svelte": 2,
"apps/mana/apps/web/src/routes/(app)/quotes/category/[category]/+page.svelte": 8,