From b27a3e688544cdb62a79adb5aff129c12626e945 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 21 Feb 2026 12:34:06 +0100 Subject: [PATCH] chore: lots of i18n --- .github/copilot-instructions.md | 3 + CLAUDE.md | 17 + src/main/shared/i18n.ts | 13 +- .../LinkedMediaPanel/LinkedMediaPanel.tsx | 36 +- src/renderer/components/Panel/Panel.tsx | 42 +- .../ProjectSelector/ProjectSelector.tsx | 74 ++-- .../components/SettingsView/SettingsView.tsx | 44 +- .../components/StyleView/StyleView.tsx | 26 +- src/renderer/components/TagsView/TagsView.tsx | 122 ++--- src/renderer/i18n/index.tsx | 10 +- src/renderer/i18n/locales/de.json | 244 +++++++--- src/renderer/i18n/locales/en.json | 130 +++++- src/renderer/i18n/locales/es.json | 416 ++++++++++++------ src/renderer/i18n/locales/fr.json | 412 +++++++++++------ src/renderer/i18n/locales/it.json | 404 +++++++++++------ tests/engine/i18nRenderLanguage.test.ts | 1 + .../components/SettingsView.i18n.test.tsx | 14 + tests/renderer/i18n.test.ts | 8 +- 18 files changed, 1355 insertions(+), 661 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aadfdcd..66d473d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -94,6 +94,9 @@ See the [TDD Requirements](#test-driven-development-tdd-requirements) section fo - UI language MUST come from the operating system locale - Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale - Keep i18n usage consistent in both renderer UI and render/preview output +- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback +- English fallback is allowed only when the requested locale is unsupported by available locale files +- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras > **No hardcoded user-facing text. No exceptions.** diff --git a/CLAUDE.md b/CLAUDE.md index 71c7b95..66d473d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,23 @@ See the [TDD Requirements](#test-driven-development-tdd-requirements) section fo --- +## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text + +**All user-facing text MUST follow proper i18n patterns.** + +- Do not hardcode UI strings directly in React components, menu templates, dialogs, or toasts +- Store UI copy in language resources and resolve text through i18n helpers/hooks +- UI language MUST come from the operating system locale +- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale +- Keep i18n usage consistent in both renderer UI and render/preview output +- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback +- English fallback is allowed only when the requested locale is unsupported by available locale files +- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras + +> **No hardcoded user-facing text. No exceptions.** + +--- + ## Architecture Principles ### Separation of Concerns diff --git a/src/main/shared/i18n.ts b/src/main/shared/i18n.ts index e781417..14c193b 100644 --- a/src/main/shared/i18n.ts +++ b/src/main/shared/i18n.ts @@ -5,14 +5,15 @@ import itJson from './i18n/locales/it.json'; import esJson from './i18n/locales/es.json'; export type SupportedLanguage = 'en' | 'de' | 'fr' | 'it' | 'es'; +export const SUPPORTED_RENDER_LANGUAGES: SupportedLanguage[] = ['en', 'de', 'fr', 'it', 'es']; type TranslationMap = Record; const en = enJson as TranslationMap; -const de = { ...en, ...(deJson as TranslationMap) }; -const fr = { ...en, ...(frJson as TranslationMap) }; -const it = { ...en, ...(itJson as TranslationMap) }; -const es = { ...en, ...(esJson as TranslationMap) }; +const de = deJson as TranslationMap; +const fr = frJson as TranslationMap; +const it = itJson as TranslationMap; +const es = esJson as TranslationMap; const catalog: Record = { en, de, fr, it, es }; @@ -47,9 +48,9 @@ export function resolveUiLanguageFromSystemLocale(systemLocale: string | undefin } export function translateRender(language: SupportedLanguage, key: string): string { - return catalog[language]?.[key] ?? catalog.en[key] ?? key; + return catalog[language]?.[key] ?? key; } export function translateMenu(language: SupportedLanguage, key: string): string { - return catalog[language]?.[key] ?? catalog.en[key] ?? key; + return catalog[language]?.[key] ?? key; } diff --git a/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx b/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx index d7ef568..e5b5722 100644 --- a/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx +++ b/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx @@ -12,6 +12,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useAppStore, MediaData } from '../../store'; import { showToast } from '../Toast'; +import { useI18n } from '../../i18n'; import './LinkedMediaPanel.css'; /** Get display name for media: title (truncated to 60 chars) or fallback to filename */ @@ -35,6 +36,7 @@ export const LinkedMediaPanel: React.FC = ({ collapsed = false, onToggleCollapse, }) => { + const { t } = useI18n(); const [linkedMedia, setLinkedMedia] = useState([]); const [isLoading, setIsLoading] = useState(false); const [dragOverIndex, setDragOverIndex] = useState(null); @@ -118,13 +120,13 @@ export const LinkedMediaPanel: React.FC = ({ await window.electronAPI?.postMedia.link(postId, media.id); } - showToast.success(`Imported and linked ${imported.length} file(s)`); + showToast.success(t('linkedMediaPanel.toast.importedLinked', { count: imported.length })); // Refresh the linked media list loadLinkedMedia(); } catch (error) { console.error('Failed to import media:', error); - showToast.error('Failed to import media'); + showToast.error(t('linkedMediaPanel.toast.importFailed')); } }; @@ -132,11 +134,11 @@ export const LinkedMediaPanel: React.FC = ({ const handleUnlink = async (mediaId: string) => { try { await window.electronAPI?.postMedia.unlink(postId, mediaId); - showToast.success('Media unlinked from post'); + showToast.success(t('linkedMediaPanel.toast.unlinked')); loadLinkedMedia(); } catch (error) { console.error('Failed to unlink media:', error); - showToast.error('Failed to unlink media'); + showToast.error(t('linkedMediaPanel.toast.unlinkFailed')); } }; @@ -144,13 +146,13 @@ export const LinkedMediaPanel: React.FC = ({ const handleLinkExisting = async (mediaId: string) => { try { await window.electronAPI?.postMedia.link(postId, mediaId); - showToast.success('Media linked to post'); + showToast.success(t('linkedMediaPanel.toast.linked')); setShowMediaPicker(false); setMediaSearchQuery(''); loadLinkedMedia(); } catch (error) { console.error('Failed to link media:', error); - showToast.error('Failed to link media'); + showToast.error(t('linkedMediaPanel.toast.linkFailed')); } }; @@ -223,7 +225,7 @@ export const LinkedMediaPanel: React.FC = ({
- 📷 Media ({linkedMedia.length}) + {t('linkedMediaPanel.collapsedTitle', { count: linkedMedia.length })}
@@ -234,19 +236,19 @@ export const LinkedMediaPanel: React.FC = ({ return (
- 📷 Linked Media + {t('linkedMediaPanel.title')}
@@ -257,13 +259,13 @@ export const LinkedMediaPanel: React.FC = ({ {showMediaPicker && (
- Select media to link + {t('linkedMediaPanel.selectMediaToLink')}
setMediaSearchQuery(e.target.value)} autoFocus @@ -271,7 +273,7 @@ export const LinkedMediaPanel: React.FC = ({
{unlinkedMedia.length === 0 ? ( -
No unlinked media available
+
{t('linkedMediaPanel.noUnlinkedMedia')}
) : ( unlinkedMedia.map(media => (
= ({
{isLoading ? ( -
Loading...
+
{t('gitSidebar.loading')}
) : linkedMedia.length === 0 ? (
-

No media linked to this post

- +

{t('linkedMediaPanel.noMediaLinked')}

+
) : (
@@ -330,7 +332,7 @@ export const LinkedMediaPanel: React.FC = ({ diff --git a/src/renderer/components/Panel/Panel.tsx b/src/renderer/components/Panel/Panel.tsx index 3fb26ad..5c1be25 100644 --- a/src/renderer/components/Panel/Panel.tsx +++ b/src/renderer/components/Panel/Panel.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useAppStore } from '../../store'; import type { TaskProgress } from '../../../main/shared/electronApi'; +import { useI18n } from '../../i18n'; import './Panel.css'; function getPostRelativePath(createdAt: string, slug: string): string | null { @@ -101,6 +102,7 @@ function buildTaskEntries(tasks: TaskProgress[]): TaskEntry[] { } export const Panel: React.FC = () => { + const { t, language } = useI18n(); const { panelVisible, panelActiveTab, @@ -191,7 +193,7 @@ export const Panel: React.FC = () => { setPostLinksEntries([...fromEntries, ...toEntries]); } catch (error) { - setPostLinksError(error instanceof Error ? error.message : 'Failed to load post links.'); + setPostLinksError(error instanceof Error ? error.message : t('panel.error.loadPostLinks')); setPostLinksEntries([]); } finally { setPostLinksLoading(false); @@ -277,7 +279,7 @@ export const Panel: React.FC = () => { if (requestIdRef.current !== currentRequestId) { return; } - setGitLogError(error instanceof Error ? error.message : 'Failed to load git log.'); + setGitLogError(error instanceof Error ? error.message : t('panel.error.loadGitLog')); setGitLogEntries([]); } finally { if (requestIdRef.current === currentRequestId) { @@ -339,7 +341,7 @@ export const Panel: React.FC = () => { className="task-cancel" onClick={() => window.electronAPI?.tasks.cancel(task.taskId)} > - Cancel + {t('common.cancel')} )}
@@ -348,7 +350,7 @@ export const Panel: React.FC = () => { return (
-
+
{canActivatePostLinks && ( )}
@@ -404,7 +406,7 @@ export const Panel: React.FC = () => {
{effectiveActivePanelTab === 'tasks' && ( recentTasks.length === 0 ? ( -
No recent tasks
+
{t('panel.noRecentTasks')}
) : (
{recentTaskEntries.map((entry) => { @@ -434,18 +436,18 @@ export const Panel: React.FC = () => { )} {effectiveActivePanelTab === 'output' && ( -
No output
+
{t('panel.noOutput')}
)} {effectiveActivePanelTab === 'post-links' && ( !canActivatePostLinks ? ( -
Open a post editor to view post links
+
{t('panel.openPostEditor')}
) : postLinksLoading ? ( -
Loading post links...
+
{t('panel.loadingPostLinks')}
) : postLinksError ? (
{postLinksError}
) : postLinksEntries.length === 0 ? ( -
No post links for this post
+
{t('panel.noPostLinks')}
) : (
{postLinksEntries.map((entry) => ( @@ -454,9 +456,9 @@ export const Panel: React.FC = () => { type="button" className="post-links-item" onClick={() => handlePostLinkClick(entry.id)} - title={`Open ${entry.title || entry.slug}`} + title={t('postLinks.openTitle', { title: entry.title || entry.slug })} > - {entry.direction} {entry.slug} + {t(`panel.direction.${entry.direction}`)} {entry.slug} ))}
@@ -465,13 +467,13 @@ export const Panel: React.FC = () => { {effectiveActivePanelTab === 'git-log' && ( !canActivateGitLog ? ( -
Open a post or media editor to view git log
+
{t('panel.openPostOrMediaEditor')}
) : gitLogLoading ? ( -
Loading git log...
+
{t('panel.loadingGitLog')}
) : gitLogError ? (
{gitLogError}
) : gitLogEntries.length === 0 ? ( -
No commits found for this item
+
{t('panel.noCommits')}
) : (
{gitLogTargetLabel}
@@ -481,7 +483,7 @@ export const Panel: React.FC = () => {
{entry.shortHash} {entry.author} - {new Date(entry.date).toLocaleString()} + {new Date(entry.date).toLocaleString(language)}
))} diff --git a/src/renderer/components/ProjectSelector/ProjectSelector.tsx b/src/renderer/components/ProjectSelector/ProjectSelector.tsx index 028b8a1..8b0f748 100644 --- a/src/renderer/components/ProjectSelector/ProjectSelector.tsx +++ b/src/renderer/components/ProjectSelector/ProjectSelector.tsx @@ -2,9 +2,11 @@ import React, { useState, useRef, useEffect } from 'react'; import { useAppStore, ProjectData, PostData, MediaData } from '../../store'; import { loadTabsForProject, saveTabsForProject } from '../../utils'; import { showToast } from '../Toast'; +import { useI18n } from '../../i18n'; import './ProjectSelector.css'; export const ProjectSelector: React.FC = () => { + const { t } = useI18n(); const { projects, activeProject, setProjects, setActiveProject, setPosts, setMedia, setSelectedPost, setSelectedMedia, removeProject, getTabState, restoreTabState, clearTabs } = useAppStore(); const [isOpen, setIsOpen] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false); @@ -83,11 +85,11 @@ export const ProjectSelector: React.FC = () => { restoreTabState(savedTabState); } - showToast.success(`Switched to ${project.name}`); + showToast.success(t('projectSelector.toast.switched', { name: project.name })); } } catch (error) { console.error('Failed to switch project:', error); - showToast.error('Failed to switch project'); + showToast.error(t('projectSelector.toast.switchFailed')); } setIsOpen(false); }; @@ -104,7 +106,7 @@ export const ProjectSelector: React.FC = () => { }); if (newProject) { setProjects([...projects, newProject as ProjectData]); - showToast.success(`Created project "${newProjectName}"`); + showToast.success(t('projectSelector.toast.created', { name: newProjectName })); setNewProjectName(''); setNewProjectDescription(''); setNewProjectDataPath(null); @@ -115,13 +117,13 @@ export const ProjectSelector: React.FC = () => { } } catch (error) { console.error('Failed to create project:', error); - showToast.error('Failed to create project'); + showToast.error(t('projectSelector.toast.createFailed')); } }; const handleSelectFolder = async () => { try { - const selectedPath = await window.electronAPI?.app.selectFolder('Select Project Location'); + const selectedPath = await window.electronAPI?.app.selectFolder(t('projectSelector.selectProjectLocation')); if (selectedPath) { setNewProjectDataPath(selectedPath); @@ -135,12 +137,12 @@ export const ProjectSelector: React.FC = () => { if (existingMetadata.description) { setNewProjectDescription(existingMetadata.description); } - showToast.info('Found existing project settings'); + showToast.info(t('projectSelector.toast.existingSettingsFound')); } } } catch (error) { console.error('Failed to select folder:', error); - showToast.error('Failed to select folder'); + showToast.error(t('projectSelector.toast.selectFolderFailed')); } }; @@ -171,16 +173,16 @@ export const ProjectSelector: React.FC = () => { const success = await window.electronAPI?.projects.deleteWithData(projectToDelete.id); if (success) { removeProject(projectToDelete.id); - showToast.success(`Deleted project "${projectToDelete.name}" and all its data`); + showToast.success(t('projectSelector.toast.deletedWithData', { name: projectToDelete.name })); setShowDeleteModal(false); setProjectToDelete(null); setDeleteConfirmText(''); } else { - showToast.error('Failed to delete project'); + showToast.error(t('projectSelector.toast.deleteFailed')); } } catch (error) { console.error('Failed to delete project:', error); - showToast.error('Failed to delete project'); + showToast.error(t('projectSelector.toast.deleteFailed')); } }; @@ -194,12 +196,12 @@ export const ProjectSelector: React.FC = () => {
))} {projects.length === 0 && ( -
No projects yet
+
{t('projectSelector.noProjectsYet')}
)}
@@ -250,7 +252,7 @@ export const ProjectSelector: React.FC = () => { - New Project + {t('projectSelector.newProject')}
@@ -260,7 +262,7 @@ export const ProjectSelector: React.FC = () => {
e.stopPropagation()}>
-

Create New Project

+

{t('projectSelector.createNewProject')}