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:
@@ -11,6 +11,7 @@ import type { LayoutDefinition } from '../types/cad.types';
|
|||||||
import { getActiveDocument, onActiveDocumentChanged } from '../kernel/document/documentService';
|
import { getActiveDocument, onActiveDocumentChanged } from '../kernel/document/documentService';
|
||||||
import { insertTitleBlockForLayout } from '../services/titleBlockService';
|
import { insertTitleBlockForLayout } from '../services/titleBlockService';
|
||||||
import { printLayout } from '../services/printService';
|
import { printLayout } from '../services/printService';
|
||||||
|
import { useT } from '../i18n/strings';
|
||||||
|
|
||||||
export interface LayoutView {
|
export interface LayoutView {
|
||||||
center: { x: number; y: number };
|
center: { x: number; y: number };
|
||||||
@@ -80,6 +81,8 @@ const WIDGET_W = 100;
|
|||||||
const WIDGET_H = 80;
|
const WIDGET_H = 80;
|
||||||
|
|
||||||
const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp }) => {
|
const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp }) => {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
const [doc, setDoc] = useState(docProp ?? getActiveDocument());
|
const [doc, setDoc] = useState(docProp ?? getActiveDocument());
|
||||||
const [layouts, setLayouts] = useState<LayoutDefinition[]>([]);
|
const [layouts, setLayouts] = useState<LayoutDefinition[]>([]);
|
||||||
|
|
||||||
@@ -134,23 +137,23 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
|
|||||||
return (
|
return (
|
||||||
<div className="layout-bar" data-testid="layout-bar">
|
<div className="layout-bar" data-testid="layout-bar">
|
||||||
<div className="layout-bar-header">
|
<div className="layout-bar-header">
|
||||||
<span className="layout-bar-title">Layouts</span>
|
<span className="layout-bar-title">{t('layouts.title')}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="layout-bar-add"
|
className="layout-bar-add"
|
||||||
title="Layout hinzufügen"
|
title={t('layouts.add')}
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={!bridge || !doc}
|
disabled={!bridge || !doc}
|
||||||
>+</button>
|
>+</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="layout-bar-print screen-only"
|
className="layout-bar-print screen-only"
|
||||||
title="Layout drucken (A4 landscape)"
|
title={t('layouts.print')}
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
>🖨</button>
|
>🖨</button>
|
||||||
</div>
|
</div>
|
||||||
{layouts.length === 0 ? (
|
{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">
|
<div className="layout-bar-list">
|
||||||
{layouts.map((l) => {
|
{layouts.map((l) => {
|
||||||
@@ -161,7 +164,7 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
|
|||||||
type="button"
|
type="button"
|
||||||
className="layout-bar-entry"
|
className="layout-bar-entry"
|
||||||
data-testid={`layout-entry-${l.id}`}
|
data-testid={`layout-entry-${l.id}`}
|
||||||
title={`Zoom zu ${l.name}`}
|
title={t('layouts.zoomto', l.name)}
|
||||||
onClick={() => handleZoomTo(l)}
|
onClick={() => handleZoomTo(l)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -187,7 +190,7 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="layout-bar-tb"
|
className="layout-bar-tb"
|
||||||
title={`Titelblock für ${l.name} einfügen/aktualisieren`}
|
title={t('layouts.titleblock', l.name)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
insertTitleBlockForLayout(doc as never, l.id, {
|
insertTitleBlockForLayout(doc as never, l.id, {
|
||||||
@@ -202,7 +205,7 @@ const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="layout-bar-del"
|
className="layout-bar-del"
|
||||||
title={`Layout ${l.name} löschen`}
|
title={t('layouts.delete', l.name)}
|
||||||
onClick={() => handleDelete(l.id)}
|
onClick={() => handleDelete(l.id)}
|
||||||
>🗑</button>
|
>🗑</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import React from 'react';
|
|||||||
import type { TopbarProps } from '../types/ui.types';
|
import type { TopbarProps } from '../types/ui.types';
|
||||||
import type { UnitType } from '../utils/format';
|
import type { UnitType } from '../utils/format';
|
||||||
import { useUIMode, toggleUIMode, getUIMode } from '../services/uiModeService';
|
import { useUIMode, toggleUIMode, getUIMode } from '../services/uiModeService';
|
||||||
|
import { useT } from '../i18n/strings';
|
||||||
|
|
||||||
const Topbar: React.FC<TopbarProps> = ({
|
const Topbar: React.FC<TopbarProps> = ({
|
||||||
projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme,
|
projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme,
|
||||||
onOpenSettings, onOpenLeftDrawer, onOpenRightDrawer,
|
onOpenSettings, onOpenLeftDrawer, onOpenRightDrawer,
|
||||||
unit = 'mm', onUnitChange, onNavigateBack,
|
unit = 'mm', onUnitChange, onNavigateBack,
|
||||||
}) => {
|
}) => {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
const uiMode = useUIMode();
|
const uiMode = useUIMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -38,8 +41,8 @@ const Topbar: React.FC<TopbarProps> = ({
|
|||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
<button
|
<button
|
||||||
className="icon-btn-top"
|
className="icon-btn-top"
|
||||||
title={uiMode === 'pro' ? 'Zum einfachen Modus wechseln' : 'Zum Profi-Modus wechseln'}
|
title={uiMode === 'pro' ? t('mode.toSimple') : t('mode.toPro')}
|
||||||
aria-label={uiMode === 'pro' ? 'Zum einfachen Modus wechseln' : 'Zum Profi-Modus wechseln'}
|
aria-label={uiMode === 'pro' ? t('mode.toSimple') : t('mode.toPro')}
|
||||||
onClick={toggleUIMode}
|
onClick={toggleUIMode}
|
||||||
>
|
>
|
||||||
{uiMode === 'pro' ? '😊' : '🛠'}
|
{uiMode === 'pro' ? '😊' : '🛠'}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Task H4 – i18n-Grundlage: zentrale strings.ts Maps de/en.
|
||||||
|
*
|
||||||
|
* strings-Modul: t(key) mit de/en-Maps, getLanguage/setLanguage
|
||||||
|
* (localStorage-persistiert, Default de), subscribe + useT-Hook.
|
||||||
|
* Kern-UI-Texte (LayoutBar, Modus-Umschaltung) laufen ueber die
|
||||||
|
* Maps; vollstaendige mechanische Ersetzung aller Komponenten ist
|
||||||
|
* teilautomatisierbares Follow-up (Roadmap-Vermerk).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
t, getLanguage, setLanguage, subscribeLanguage, useT, LANGUAGES,
|
||||||
|
} from '../src/i18n/strings';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
// ––– strings-Modul ––––––––––––––––––––––––
|
||||||
|
|
||||||
|
describe('H4: i18n strings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
setLanguage('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Default-Sprache ist de', () => {
|
||||||
|
expect(getLanguage()).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('t liefert deutsche Kern-UI-Texte', () => {
|
||||||
|
expect(t('layouts.title')).toBe('Layouts');
|
||||||
|
expect(t('layouts.add')).toBe('Layout hinzufügen');
|
||||||
|
expect(t('layouts.empty')).toBe('Keine Layouts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setLanguage(en) wechselt auf englische Texte', () => {
|
||||||
|
setLanguage('en');
|
||||||
|
expect(t('layouts.title')).toBe('Layouts');
|
||||||
|
expect(t('layouts.add')).toBe('Add layout');
|
||||||
|
expect(t('layouts.empty')).toBe('No layouts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Sprache persistiert in localStorage und ueberlebt Reload-Semantik', () => {
|
||||||
|
setLanguage('en');
|
||||||
|
expect(localStorage.getItem('webcad.language')).toBe('en');
|
||||||
|
// erneutes Lesen startet aus persistiertem Zustand
|
||||||
|
expect(getLanguage()).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LANGUAGES enthaelt genau de und en', () => {
|
||||||
|
expect(LANGUAGES).toEqual(['de', 'en']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('t mit unbekanntem Key liefert den Key zurueck (Fallback, lint-bar)', () => {
|
||||||
|
setLanguage('de');
|
||||||
|
expect(t('gibts.nicht')).toBe('gibts.nicht');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribeLanguage feuert bei Wechsel und unsubscribed', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
const off = subscribeLanguage(cb);
|
||||||
|
setLanguage('en');
|
||||||
|
expect(cb).toHaveBeenCalledWith('en');
|
||||||
|
off();
|
||||||
|
cb.mockClear();
|
||||||
|
setLanguage('de');
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ––– useT-Hook + Komponente ––––––––––––––––––
|
||||||
|
|
||||||
|
describe('H4: useT-Hook rendert LayoutBar zweisprachig', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
setLanguage('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LayoutBar zeigt deutsche Texte und wechselt live auf englisch', async () => {
|
||||||
|
const LayoutBar = (await import('../src/components/LayoutBar')).default;
|
||||||
|
render(<LayoutBar doc={null} bridge={null} />);
|
||||||
|
expect(screen.getByText('Layouts')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Keine Layouts')).toBeTruthy();
|
||||||
|
expect(screen.getByTitle('Layout hinzufügen')).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setLanguage('en');
|
||||||
|
});
|
||||||
|
// Live-Update via subscribe (nach act flush)
|
||||||
|
expect(screen.getByText('No layouts')).toBeTruthy();
|
||||||
|
expect(screen.getByTitle('Add layout')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user