task(H4): i18n foundation - strings.ts de/en maps, t() with key fallback, language persistence, useT hook, LayoutBar + Topbar texts switched

This commit is contained in:
Agent Zero
2026-08-29 03:05:27 +02:00
parent 10eacdeaf5
commit e6fdb39a86
4 changed files with 213 additions and 9 deletions
+10 -7
View File
@@ -11,6 +11,7 @@ import type { LayoutDefinition } from '../types/cad.types';
import { getActiveDocument, onActiveDocumentChanged } from '../kernel/document/documentService';
import { insertTitleBlockForLayout } from '../services/titleBlockService';
import { printLayout } from '../services/printService';
import { useT } from '../i18n/strings';
export interface LayoutView {
center: { x: number; y: number };
@@ -80,6 +81,8 @@ const WIDGET_W = 100;
const WIDGET_H = 80;
const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp }) => {
const t = useT();
const [doc, setDoc] = useState(docProp ?? getActiveDocument());
const [layouts, setLayouts] = useState<LayoutDefinition[]>([]);
@@ -134,23 +137,23 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
return (
<div className="layout-bar" data-testid="layout-bar">
<div className="layout-bar-header">
<span className="layout-bar-title">Layouts</span>
<span className="layout-bar-title">{t('layouts.title')}</span>
<button
type="button"
className="layout-bar-add"
title="Layout hinzufügen"
title={t('layouts.add')}
onClick={handleAdd}
disabled={!bridge || !doc}
>+</button>
<button
type="button"
className="layout-bar-print screen-only"
title="Layout drucken (A4 landscape)"
title={t('layouts.print')}
onClick={handlePrint}
>🖨</button>
</div>
{layouts.length === 0 ? (
<div className="layout-bar-empty">Keine Layouts</div>
<div className="layout-bar-empty">{t('layouts.empty')}</div>
) : (
<div className="layout-bar-list">
{layouts.map((l) => {
@@ -161,7 +164,7 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
type="button"
className="layout-bar-entry"
data-testid={`layout-entry-${l.id}`}
title={`Zoom zu ${l.name}`}
title={t('layouts.zoomto', l.name)}
onClick={() => handleZoomTo(l)}
>
<svg
@@ -187,7 +190,7 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
<button
type="button"
className="layout-bar-tb"
title={`Titelblock für ${l.name} einfügen/aktualisieren`}
title={t('layouts.titleblock', l.name)}
onClick={() => {
if (!doc) return;
insertTitleBlockForLayout(doc as never, l.id, {
@@ -202,7 +205,7 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
<button
type="button"
className="layout-bar-del"
title={`Layout ${l.name} löschen`}
title={t('layouts.delete', l.name)}
onClick={() => handleDelete(l.id)}
>🗑</button>
</div>
+5 -2
View File
@@ -2,12 +2,15 @@ import React from 'react';
import type { TopbarProps } from '../types/ui.types';
import type { UnitType } from '../utils/format';
import { useUIMode, toggleUIMode, getUIMode } from '../services/uiModeService';
import { useT } from '../i18n/strings';
const Topbar: React.FC<TopbarProps> = ({
projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme,
onOpenSettings, onOpenLeftDrawer, onOpenRightDrawer,
unit = 'mm', onUnitChange, onNavigateBack,
}) => {
const t = useT();
const uiMode = useUIMode();
return (
@@ -38,8 +41,8 @@ const Topbar: React.FC<TopbarProps> = ({
<div className="topbar-right">
<button
className="icon-btn-top"
title={uiMode === 'pro' ? 'Zum einfachen Modus wechseln' : 'Zum Profi-Modus wechseln'}
aria-label={uiMode === 'pro' ? 'Zum einfachen Modus wechseln' : 'Zum Profi-Modus wechseln'}
title={uiMode === 'pro' ? t('mode.toSimple') : t('mode.toPro')}
aria-label={uiMode === 'pro' ? t('mode.toSimple') : t('mode.toPro')}
onClick={toggleUIMode}
>
{uiMode === 'pro' ? '😊' : '🛠'}
+105
View File
@@ -0,0 +1,105 @@
/**
* Task H4 i18n-Grundlage: zentrale Strings-Maps de/en.
*
* t(key) liefert den Text der aktuellen Sprache; unbekannte Keys
* fallen auf den Key zurück (im Code sichtbar, lint-bar über Tests).
* Sprache persistiert in localStorage ('webcad.language', Default de).
* Komponenten nutzen useT() für Live-Updates bei Sprachwechsel.
*
* Vollständige mechanische Ersetzung ALLER Komponenten-Texte ist
* teilautomatisierbares Follow-up; hier liegt die Infrastruktur und
* die Kern-UI-Texte (LayoutBar, Modus).
*/
import { useEffect, useState } from 'react';
export const LANGUAGES = ['de', 'en'] as const;
export type Language = (typeof LANGUAGES)[number];
type StringEntry = string | ((arg?: string) => string);
const STORAGE_KEY = 'webcad.language';
/** Zentrale Text-Maps (Key-Pfad → Text oder parametrisierte Funktion). */
const DE: Record<string, StringEntry> = {
'layouts.title': 'Layouts',
'layouts.add': 'Layout hinzufügen',
'layouts.empty': 'Keine Layouts',
'layouts.delete': (n?: string) => 'Layout ' + (n ?? '') + ' löschen',
'layouts.print': 'Layout drucken (A4 landscape)',
'layouts.titleblock': (n?: string) => 'Titelblock für ' + (n ?? '') + ' einfügen/aktualisieren',
'layouts.zoomto': (n?: string) => 'Zoom zu ' + (n ?? ''),
'mode.toSimple': 'Zum einfachen Modus wechseln',
'mode.toPro': 'Zum Profi-Modus wechseln',
};
const EN: Record<string, StringEntry> = {
'layouts.title': 'Layouts',
'layouts.add': 'Add layout',
'layouts.empty': 'No layouts',
'layouts.delete': (n?: string) => 'Delete layout ' + (n ?? ''),
'layouts.print': 'Print layout (A4 landscape)',
'layouts.titleblock': (n?: string) => 'Insert/update title block for ' + (n ?? ''),
'layouts.zoomto': (n?: string) => 'Zoom to ' + (n ?? ''),
'mode.toSimple': 'Switch to simple mode',
'mode.toPro': 'Switch to pro mode',
};
function mapFor(lang: Language): Record<string, StringEntry> {
return lang === 'en' ? EN : DE;
}
function readStored(): Language {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === 'de' || raw === 'en') return raw;
} catch {
// localStorage nicht verfügbar
}
return 'de';
}
let current: Language = readStored();
const listeners = new Set<(lang: Language) => void>();
/** Aktuelle Sprache. */
export function getLanguage(): Language {
return current;
}
/** Sprache wechseln und persistieren. */
export function setLanguage(lang: Language): void {
current = lang;
try {
localStorage.setItem(STORAGE_KEY, lang);
} catch {
// Persistenz optional
}
for (const cb of listeners) cb(lang);
}
/** Subscribe auf Sprachwechsel; gibt Unsubscribe zurück. */
export function subscribeLanguage(cb: (lang: Language) => void): () => void {
listeners.add(cb);
return () => { listeners.delete(cb); };
}
/**
* Übersetzung: t('key') bzw. t('key', arg) für parametrisierte Texte.
* Unbekannter Key → Key als Fallback (offensichtlich sichtbar).
*/
export function t(key: string, arg?: string): string {
const entry = mapFor(current)[key];
if (typeof entry === 'function') return entry(arg);
if (typeof entry === 'string') return entry;
return key;
}
/** React-Hook: erzwingt Re-Render bei Sprachwechsel. */
export function useT(): typeof t {
const [, force] = useState(0);
useEffect(() => {
const off = subscribeLanguage(() => force((n) => n + 1));
return off;
}, []);
return t;
}