chore: lots of i18n

This commit is contained in:
2026-02-21 12:34:06 +01:00
parent c991015ea8
commit b27a3e6885
18 changed files with 1355 additions and 661 deletions

View File

@@ -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<LinkedMediaPanelProps> = ({
collapsed = false,
onToggleCollapse,
}) => {
const { t } = useI18n();
const [linkedMedia, setLinkedMedia] = useState<MediaData[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
@@ -118,13 +120,13 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
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<LinkedMediaPanelProps> = ({
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<LinkedMediaPanelProps> = ({
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<LinkedMediaPanelProps> = ({
<div className="linked-media-panel collapsed" onClick={onToggleCollapse}>
<div className="panel-header">
<span className="panel-title">
📷 Media ({linkedMedia.length})
{t('linkedMediaPanel.collapsedTitle', { count: linkedMedia.length })}
</span>
<span className="expand-icon"></span>
</div>
@@ -234,19 +236,19 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
return (
<div className="linked-media-panel">
<div className="panel-header" onClick={onToggleCollapse}>
<span className="panel-title">📷 Linked Media</span>
<span className="panel-title">{t('linkedMediaPanel.title')}</span>
<div className="panel-actions">
<button
className="panel-action"
onClick={(e) => { e.stopPropagation(); handleImportMedia(); }}
title="Import and link media"
title={t('linkedMediaPanel.importAndLink')}
>
+
</button>
<button
className="panel-action"
onClick={(e) => { e.stopPropagation(); setShowMediaPicker(!showMediaPicker); }}
title="Link existing media"
title={t('linkedMediaPanel.linkExisting')}
>
🔗
</button>
@@ -257,13 +259,13 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
{showMediaPicker && (
<div className="media-picker">
<div className="media-picker-header">
<span>Select media to link</span>
<span>{t('linkedMediaPanel.selectMediaToLink')}</span>
<button onClick={() => { setShowMediaPicker(false); setMediaSearchQuery(''); }}>×</button>
</div>
<div className="media-picker-search">
<input
type="text"
placeholder="Search media..."
placeholder={t('linkedMediaPanel.searchPlaceholder')}
value={mediaSearchQuery}
onChange={(e) => setMediaSearchQuery(e.target.value)}
autoFocus
@@ -271,7 +273,7 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
</div>
<div className="media-picker-grid">
{unlinkedMedia.length === 0 ? (
<div className="no-media">No unlinked media available</div>
<div className="no-media">{t('linkedMediaPanel.noUnlinkedMedia')}</div>
) : (
unlinkedMedia.map(media => (
<div
@@ -295,11 +297,11 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
<div className="panel-content">
{isLoading ? (
<div className="loading">Loading...</div>
<div className="loading">{t('gitSidebar.loading')}</div>
) : linkedMedia.length === 0 ? (
<div className="empty-state">
<p>No media linked to this post</p>
<button onClick={handleImportMedia}>Import Media</button>
<p>{t('linkedMediaPanel.noMediaLinked')}</p>
<button onClick={handleImportMedia}>{t('linkedMediaPanel.importMedia')}</button>
</div>
) : (
<div className="media-list">
@@ -330,7 +332,7 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
<button
className="unlink-btn"
onClick={(e) => { e.stopPropagation(); handleUnlink(media.id); }}
title="Unlink from post"
title={t('linkedMediaPanel.unlinkFromPost')}
>
×
</button>

View File

@@ -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')}
</button>
)}
</div>
@@ -348,7 +350,7 @@ export const Panel: React.FC = () => {
return (
<div className="panel">
<div className="panel-header">
<div className="panel-tabs" role="tablist" aria-label="Panel tabs">
<div className="panel-tabs" role="tablist" aria-label={t('panel.tabsAria')}>
<button
type="button"
role="tab"
@@ -356,7 +358,7 @@ export const Panel: React.FC = () => {
aria-selected={effectiveActivePanelTab === 'tasks'}
onClick={() => setPanelActiveTab('tasks')}
>
Tasks
{t('common.tasks')}
</button>
<button
type="button"
@@ -365,7 +367,7 @@ export const Panel: React.FC = () => {
aria-selected={effectiveActivePanelTab === 'output'}
onClick={() => setPanelActiveTab('output')}
>
Output
{t('panel.output')}
</button>
{canActivatePostLinks && (
<button
@@ -375,7 +377,7 @@ export const Panel: React.FC = () => {
aria-selected={effectiveActivePanelTab === 'post-links'}
onClick={() => setPanelActiveTab('post-links')}
>
Post Links
{t('panel.postLinks')}
</button>
)}
<button
@@ -390,13 +392,13 @@ export const Panel: React.FC = () => {
}
}}
>
Git Log
{t('panel.gitLog')}
</button>
</div>
<button
className="panel-close"
onClick={() => useAppStore.getState().togglePanel()}
title="Close Panel"
title={t('panel.closeTitle')}
>
×
</button>
@@ -404,7 +406,7 @@ export const Panel: React.FC = () => {
<div className="panel-content">
{effectiveActivePanelTab === 'tasks' && (
recentTasks.length === 0 ? (
<div className="panel-empty">No recent tasks</div>
<div className="panel-empty">{t('panel.noRecentTasks')}</div>
) : (
<div className="task-list">
{recentTaskEntries.map((entry) => {
@@ -434,18 +436,18 @@ export const Panel: React.FC = () => {
)}
{effectiveActivePanelTab === 'output' && (
<div className="panel-empty">No output</div>
<div className="panel-empty">{t('panel.noOutput')}</div>
)}
{effectiveActivePanelTab === 'post-links' && (
!canActivatePostLinks ? (
<div className="panel-empty">Open a post editor to view post links</div>
<div className="panel-empty">{t('panel.openPostEditor')}</div>
) : postLinksLoading ? (
<div className="panel-empty">Loading post links...</div>
<div className="panel-empty">{t('panel.loadingPostLinks')}</div>
) : postLinksError ? (
<div className="panel-empty">{postLinksError}</div>
) : postLinksEntries.length === 0 ? (
<div className="panel-empty">No post links for this post</div>
<div className="panel-empty">{t('panel.noPostLinks')}</div>
) : (
<div className="post-links-list">
{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 })}
>
<span className="post-links-direction">{entry.direction} {entry.slug}</span>
<span className="post-links-direction">{t(`panel.direction.${entry.direction}`)} {entry.slug}</span>
</button>
))}
</div>
@@ -465,13 +467,13 @@ export const Panel: React.FC = () => {
{effectiveActivePanelTab === 'git-log' && (
!canActivateGitLog ? (
<div className="panel-empty">Open a post or media editor to view git log</div>
<div className="panel-empty">{t('panel.openPostOrMediaEditor')}</div>
) : gitLogLoading ? (
<div className="panel-empty">Loading git log...</div>
<div className="panel-empty">{t('panel.loadingGitLog')}</div>
) : gitLogError ? (
<div className="panel-empty">{gitLogError}</div>
) : gitLogEntries.length === 0 ? (
<div className="panel-empty">No commits found for this item</div>
<div className="panel-empty">{t('panel.noCommits')}</div>
) : (
<div className="git-log-list">
<div className="git-log-target">{gitLogTargetLabel}</div>
@@ -481,7 +483,7 @@ export const Panel: React.FC = () => {
<div className="git-log-meta">
<span className="git-log-hash">{entry.shortHash}</span>
<span>{entry.author}</span>
<span>{new Date(entry.date).toLocaleString()}</span>
<span>{new Date(entry.date).toLocaleString(language)}</span>
</div>
</div>
))}

View File

@@ -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 = () => {
<button
className="project-selector-trigger"
onClick={() => setIsOpen(!isOpen)}
title="Switch project"
title={t('projectSelector.switchProject')}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="project-icon">
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"/>
</svg>
<span className="project-name">{activeProject?.name || 'Select Project'}</span>
<span className="project-name">{activeProject?.name || t('projectSelector.selectProject')}</span>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="dropdown-arrow">
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"/>
</svg>
@@ -208,7 +210,7 @@ export const ProjectSelector: React.FC = () => {
{isOpen && (
<div className="project-dropdown">
<div className="project-dropdown-header">
<span>PROJECTS</span>
<span>{t('projectSelector.projectsHeader')}</span>
</div>
<div className="project-list">
{projects.map(project => (
@@ -231,7 +233,7 @@ export const ProjectSelector: React.FC = () => {
<button
className="project-item-delete"
onClick={(e) => openDeleteModal(e, project)}
title={`Delete ${project.name}`}
title={t('projectSelector.deleteProjectTitle', { name: project.name })}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"/>
@@ -242,7 +244,7 @@ export const ProjectSelector: React.FC = () => {
</div>
))}
{projects.length === 0 && (
<div className="project-empty">No projects yet</div>
<div className="project-empty">{t('projectSelector.noProjectsYet')}</div>
)}
</div>
<div className="project-dropdown-footer">
@@ -250,7 +252,7 @@ export const ProjectSelector: React.FC = () => {
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
New Project
{t('projectSelector.newProject')}
</button>
</div>
</div>
@@ -260,7 +262,7 @@ export const ProjectSelector: React.FC = () => {
<div className="modal-overlay" onClick={handleCloseCreateModal}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>Create New Project</h3>
<h3>{t('projectSelector.createNewProject')}</h3>
<button className="modal-close" onClick={handleCloseCreateModal}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
@@ -270,33 +272,33 @@ export const ProjectSelector: React.FC = () => {
<form onSubmit={handleCreateProject}>
<div className="modal-body">
<div className="form-field">
<label htmlFor="project-name">Project Name</label>
<label htmlFor="project-name">{t('projectSelector.projectName')}</label>
<input
id="project-name"
type="text"
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
placeholder="My Blog"
placeholder={t('projectSelector.projectNamePlaceholder')}
autoFocus
/>
</div>
<div className="form-field">
<label htmlFor="project-description">Description (optional)</label>
<label htmlFor="project-description">{t('projectSelector.descriptionOptional')}</label>
<textarea
id="project-description"
value={newProjectDescription}
onChange={e => setNewProjectDescription(e.target.value)}
placeholder="A brief description of this project..."
placeholder={t('projectSelector.descriptionPlaceholder')}
rows={3}
/>
</div>
<div className="form-field">
<label>Project Location</label>
<label>{t('projectSelector.projectLocation')}</label>
<div className="folder-picker">
{newProjectDataPath ? (
<div className="folder-path-display">
<span className="folder-path" title={newProjectDataPath}>{newProjectDataPath}</span>
<button type="button" className="btn-icon" onClick={handleClearFolder} title="Use default location">
<button type="button" className="btn-icon" onClick={handleClearFolder} title={t('projectSelector.useDefaultLocation')}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
</svg>
@@ -304,27 +306,27 @@ export const ProjectSelector: React.FC = () => {
</div>
) : (
<div className="folder-default-info">
<span className="default-label">Default (internal storage)</span>
<span className="default-label">{t('projectSelector.defaultInternalStorage')}</span>
</div>
)}
<button type="button" className="btn-secondary btn-small" onClick={handleSelectFolder}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"/>
</svg>
Choose Folder...
{t('projectSelector.chooseFolder')}
</button>
</div>
<p className="form-hint">
Choose a custom folder for cloud storage backup, or use the default internal storage.
{t('projectSelector.projectLocationHint')}
</p>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={handleCloseCreateModal}>
Cancel
{t('common.cancel')}
</button>
<button type="submit" className="btn-primary" disabled={!newProjectName.trim()}>
Create Project
{t('projectSelector.createProject')}
</button>
</div>
</form>
@@ -336,7 +338,7 @@ export const ProjectSelector: React.FC = () => {
<div className="modal-overlay" onClick={() => setShowDeleteModal(false)}>
<div className="modal-content modal-danger" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>Delete Project</h3>
<h3>{t('projectSelector.deleteProject')}</h3>
<button className="modal-close" onClick={() => setShowDeleteModal(false)}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
@@ -346,15 +348,15 @@ export const ProjectSelector: React.FC = () => {
<form onSubmit={handleDeleteProject}>
<div className="modal-body">
<p className="delete-warning">
This will permanently delete the project <strong>"{projectToDelete.name}"</strong> and all its data including:
{t('projectSelector.deleteWarning', { name: projectToDelete.name })}
</p>
<ul className="delete-list">
<li>All blog posts</li>
<li>All media files</li>
<li>All project settings</li>
<li>{t('projectSelector.deleteItemPosts')}</li>
<li>{t('projectSelector.deleteItemMedia')}</li>
<li>{t('projectSelector.deleteItemSettings')}</li>
</ul>
<p className="delete-confirm-text">
Type <strong>{projectToDelete.name}</strong> to confirm deletion:
{t('projectSelector.typeToConfirm', { name: projectToDelete.name })}
</p>
<div className="form-field">
<input
@@ -368,14 +370,14 @@ export const ProjectSelector: React.FC = () => {
</div>
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={() => setShowDeleteModal(false)}>
Cancel
{t('common.cancel')}
</button>
<button
type="submit"
className="btn-danger"
disabled={deleteConfirmText !== projectToDelete.name}
>
Delete Project
{t('projectSelector.deleteProject')}
</button>
</div>
</form>

View File

@@ -2,6 +2,11 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import { useI18n } from '../../i18n';
import {
resolveSupportedRenderLanguage,
SUPPORTED_RENDER_LANGUAGES,
type SupportedLanguage,
} from '../../../main/shared/i18n';
import './SettingsView.css';
// Export category IDs for sidebar navigation
@@ -33,6 +38,14 @@ interface CategoryRenderSettings {
showTitle: boolean;
}
const RENDER_LANGUAGE_LABEL_KEY: Record<SupportedLanguage, string> = {
en: 'settings.language.english',
de: 'settings.language.german',
fr: 'settings.language.french',
it: 'settings.language.italian',
es: 'settings.language.spanish',
};
const defaultCredentials: Credentials = {
ftpHost: '',
ftpUser: '',
@@ -123,7 +136,7 @@ export const SettingsView: React.FC = () => {
const [projectDataPath, setProjectDataPath] = useState('');
const [projectPublicUrl, setProjectPublicUrl] = useState('');
const [defaultProjectPath, setDefaultProjectPath] = useState('');
const [projectMainLanguage, setProjectMainLanguage] = useState('en');
const [projectMainLanguage, setProjectMainLanguage] = useState<SupportedLanguage>('en');
const [projectDefaultAuthor, setProjectDefaultAuthor] = useState('');
const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50);
@@ -169,7 +182,7 @@ export const SettingsView: React.FC = () => {
setProjectPublicUrl('');
}
if (metadata?.mainLanguage) {
setProjectMainLanguage(metadata.mainLanguage);
setProjectMainLanguage(resolveSupportedRenderLanguage(metadata.mainLanguage));
}
if (metadata?.defaultAuthor) {
setProjectDefaultAuthor(metadata.defaultAuthor);
@@ -302,7 +315,7 @@ export const SettingsView: React.FC = () => {
description: projectDescription.trim(),
dataPath: projectDataPath.trim() || undefined,
publicUrl: projectPublicUrl.trim() || undefined,
mainLanguage: projectMainLanguage,
mainLanguage: resolveSupportedRenderLanguage(projectMainLanguage),
defaultAuthor: projectDefaultAuthor.trim() || undefined,
maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))),
categorySettings,
@@ -415,28 +428,11 @@ export const SettingsView: React.FC = () => {
<select
id="project-language"
value={projectMainLanguage}
onChange={(e) => setProjectMainLanguage(e.target.value)}
onChange={(e) => setProjectMainLanguage(resolveSupportedRenderLanguage(e.target.value))}
>
<option value="en">{t('settings.language.english')}</option>
<option value="de">{t('settings.language.german')}</option>
<option value="es">{t('settings.language.spanish')}</option>
<option value="fr">{t('settings.language.french')}</option>
<option value="it">{t('settings.language.italian')}</option>
<option value="pt">{t('settings.language.portuguese')}</option>
<option value="nl">{t('settings.language.dutch')}</option>
<option value="pl">{t('settings.language.polish')}</option>
<option value="ru">{t('settings.language.russian')}</option>
<option value="ja">{t('settings.language.japanese')}</option>
<option value="zh">{t('settings.language.chinese')}</option>
<option value="ko">{t('settings.language.korean')}</option>
<option value="ar">{t('settings.language.arabic')}</option>
<option value="hi">{t('settings.language.hindi')}</option>
<option value="tr">{t('settings.language.turkish')}</option>
<option value="sv">{t('settings.language.swedish')}</option>
<option value="da">{t('settings.language.danish')}</option>
<option value="no">{t('settings.language.norwegian')}</option>
<option value="fi">{t('settings.language.finnish')}</option>
<option value="cs">{t('settings.language.czech')}</option>
{SUPPORTED_RENDER_LANGUAGES.map((language) => (
<option key={language} value={language}>{t(RENDER_LANGUAGE_LABEL_KEY[language])}</option>
))}
</select>
</SettingRow>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import { useI18n } from '../../i18n';
import { PICO_THEME_NAMES, PICO_THEME_PREVIEW_TOKENS, getRendererPicoTheme, ensureRendererPicoThemeStylesheet, type PicoThemeName } from '../../utils/picoTheme';
import './StyleView.css';
@@ -12,6 +13,7 @@ function toDisplayName(theme: PicoThemeName): string {
}
export const StyleView: React.FC = () => {
const { t } = useI18n();
const { activeProject, picoTheme, setPicoTheme } = useAppStore();
const [selectedTheme, setSelectedTheme] = useState<PicoThemeName>(getRendererPicoTheme(picoTheme));
const [previewMode, setPreviewMode] = useState<PreviewMode>('auto');
@@ -51,10 +53,10 @@ export const StyleView: React.FC = () => {
setIsApplying(true);
await window.electronAPI?.meta.updateProjectMetadata({ picoTheme: selectedTheme });
setPicoTheme(selectedTheme);
showToast.success(`Applied theme: ${toDisplayName(selectedTheme)}`);
showToast.success(t('styleView.toast.appliedTheme', { theme: toDisplayName(selectedTheme) }));
} catch (error) {
console.error('Failed to apply style theme:', error);
showToast.error('Failed to apply theme');
showToast.error(t('styleView.toast.applyThemeFailed'));
} finally {
setIsApplying(false);
}
@@ -63,11 +65,11 @@ export const StyleView: React.FC = () => {
return (
<div className="style-view">
<div className="style-view-header">
<h2>Style</h2>
<p>Select a Pico CSS theme and preview the top posts before applying.</p>
<h2>{t('styleView.title')}</h2>
<p>{t('styleView.subtitle')}</p>
</div>
<div className="style-theme-picker" role="group" aria-label="Pico Theme Picker">
<div className="style-theme-picker" role="group" aria-label={t('styleView.themePickerAria')}>
{PICO_THEME_NAMES.map((theme) => {
const isSelected = selectedTheme === theme;
const preview = PICO_THEME_PREVIEW_TOKENS[theme];
@@ -104,15 +106,15 @@ export const StyleView: React.FC = () => {
<div className="style-apply-row">
<label className="style-preview-mode-control">
<span>Preview mode</span>
<span>{t('styleView.previewMode')}</span>
<select
aria-label="Preview mode"
aria-label={t('styleView.previewMode')}
value={previewMode}
onChange={(event) => setPreviewMode(event.target.value as PreviewMode)}
>
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">{t('styleView.mode.auto')}</option>
<option value="light">{t('styleView.mode.light')}</option>
<option value="dark">{t('styleView.mode.dark')}</option>
</select>
</label>
<button
@@ -120,13 +122,13 @@ export const StyleView: React.FC = () => {
onClick={handleApplyTheme}
disabled={isApplying || picoTheme === selectedTheme}
>
Apply Theme
{t('styleView.applyTheme')}
</button>
</div>
<div className="style-preview-container">
<iframe
title="Theme preview"
title={t('styleView.themePreviewTitle')}
className="style-preview-frame"
src={previewUrl}
/>

View File

@@ -3,6 +3,7 @@ import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import { getContrastColor } from '../../utils/color';
import { subscribeToTagEvents } from '../../utils/tagEventSubscriptions';
import { useI18n } from '../../i18n';
import './TagsView.css';
// Types
@@ -46,7 +47,8 @@ const TagCloudItem: React.FC<{
isSelected: boolean;
onSelect: (name: string) => void;
maxCount: number;
}> = ({ tag, isSelected, onSelect, maxCount }) => {
title: string;
}> = ({ tag, isSelected, onSelect, maxCount, title }) => {
// Calculate font size based on count (range: 0.8rem to 2rem)
const minSize = 0.85;
const maxSize = 1.8;
@@ -69,7 +71,7 @@ const TagCloudItem: React.FC<{
className={`tag-cloud-item ${isSelected ? 'selected' : ''} ${hasColor ? 'has-color' : ''}`}
style={style}
onClick={() => onSelect(tag.name)}
title={`${tag.count} post${tag.count !== 1 ? 's' : ''}`}
title={title}
>
{tag.name}
<span className="tag-count">{tag.count}</span>
@@ -128,6 +130,7 @@ const SectionHeader: React.FC<{
);
export const TagsView: React.FC = () => {
const { t } = useI18n();
const { showErrorModal } = useAppStore();
// State
@@ -198,7 +201,7 @@ export const TagsView: React.FC = () => {
// Create tag
const handleCreateTag = async () => {
if (!newTagName.trim()) {
showToast.error('Tag name is required');
showToast.error(t('tagsView.toast.tagNameRequired'));
return;
}
@@ -209,7 +212,7 @@ export const TagsView: React.FC = () => {
});
setNewTagName('');
setNewTagColor('');
showToast.success('Tag created');
showToast.success(t('tagsView.toast.tagCreated'));
loadTags();
} catch (error) {
const err = error as Error;
@@ -224,14 +227,14 @@ export const TagsView: React.FC = () => {
try {
const result = await window.electronAPI?.tags.delete(deleteConfirm.tagId);
if (result?.success) {
showToast.success(`Tag deleted. ${result.postsUpdated} post(s) updated.`);
showToast.success(t('tagsView.toast.tagDeleted', { postsUpdated: result.postsUpdated }));
setSelectedTags(prev => prev.filter(n => n !== deleteConfirm.tagName));
loadTags();
}
} catch (error) {
const err = error as Error;
showErrorModal({
title: 'Delete Failed',
title: t('tagsView.error.deleteFailedTitle'),
message: err.message,
});
} finally {
@@ -262,7 +265,7 @@ export const TagsView: React.FC = () => {
await window.electronAPI?.tags.rename(editingTagId, editTagName.trim());
}
showToast.success('Tag updated');
showToast.success(t('tagsView.toast.tagUpdated'));
setEditingTagId(null);
loadTags();
} catch (error) {
@@ -279,7 +282,7 @@ export const TagsView: React.FC = () => {
// Find target tag
const targetTag = allTags.find(t => t.name === mergeConfirm.targetName);
if (!targetTag) {
showToast.error('Target tag not found');
showToast.error(t('tagsView.toast.targetTagNotFound'));
return;
}
@@ -289,7 +292,7 @@ export const TagsView: React.FC = () => {
);
if (sourceTags.length === 0) {
showToast.error('No source tags to merge');
showToast.error(t('tagsView.toast.noSourceTagsToMerge'));
return;
}
@@ -300,7 +303,11 @@ export const TagsView: React.FC = () => {
if (result?.success) {
showToast.success(
`Merged ${result.tagsDeleted} tag(s) into "${result.targetTag}". ${result.postsUpdated} post(s) updated.`
t('tagsView.toast.tagsMerged', {
tagsDeleted: result.tagsDeleted,
targetTag: result.targetTag,
postsUpdated: result.postsUpdated,
})
);
setSelectedTags([]);
setMergeTargetName('');
@@ -309,7 +316,7 @@ export const TagsView: React.FC = () => {
} catch (error) {
const err = error as Error;
showErrorModal({
title: 'Merge Failed',
title: t('tagsView.error.mergeFailedTitle'),
message: err.message,
});
} finally {
@@ -323,9 +330,9 @@ export const TagsView: React.FC = () => {
const result = await window.electronAPI?.tags.syncFromPosts();
if (result) {
if (result.added.length > 0) {
showToast.success(`Discovered ${result.added.length} new tag(s)`);
showToast.success(t('tagsView.toast.discoveredTags', { count: result.added.length }));
} else {
showToast.info('All tags are already synced');
showToast.info(t('tagsView.toast.alreadySynced'));
}
loadTags();
}
@@ -349,23 +356,23 @@ export const TagsView: React.FC = () => {
return (
<div className="tags-view">
<div className="tags-view-header">
<h2>Tag Management</h2>
<p className="text-muted">Manage your blog's tags, assign colors, and perform bulk operations.</p>
<h2>{t('tagsView.title')}</h2>
<p className="text-muted">{t('tagsView.subtitle')}</p>
</div>
<div className="tags-view-content">
{/* Tag Cloud Section */}
<SectionHeader
id="tags-section-cloud"
title="Tag Cloud"
description="Click tags to select them for bulk operations. Hover to see post counts."
title={t('tagsView.cloud.title')}
description={t('tagsView.cloud.description')}
>
{isLoading ? (
<div className="tags-loading">Loading tags...</div>
<div className="tags-loading">{t('tagsView.loadingTags')}</div>
) : tagsWithCounts.length === 0 ? (
<div className="tags-empty">
<p>No tags found</p>
<button onClick={handleSyncFromPosts}>Discover tags from posts</button>
<p>{t('tagsView.noTagsFound')}</p>
<button onClick={handleSyncFromPosts}>{t('tagsView.discoverFromPosts')}</button>
</div>
) : (
<>
@@ -377,13 +384,17 @@ export const TagsView: React.FC = () => {
isSelected={selectedTags.includes(tag.name)}
onSelect={handleTagSelect}
maxCount={maxCount}
title={t('tagsView.tagCountTitle', {
count: tag.count,
item: tag.count === 1 ? t('tagsView.postsSingular') : t('tagsView.postsPlural'),
})}
/>
))}
</div>
{selectedTags.length > 0 && (
<div className="tag-selection-info">
<span>{selectedTags.length} tag(s) selected</span>
<button onClick={handleClearSelection}>Clear selection</button>
<span>{t('tagsView.selectedCount', { count: selectedTags.length })}</span>
<button onClick={handleClearSelection}>{t('tagsView.clearSelection')}</button>
</div>
)}
</>
@@ -393,16 +404,16 @@ export const TagsView: React.FC = () => {
{/* Tag Management Section */}
<SectionHeader
id="tags-section-manage"
title="Create & Edit Tags"
description="Create new tags or edit existing ones. Assign colors to make tags visually distinct."
title={t('tagsView.manage.title')}
description={t('tagsView.manage.description')}
>
{/* Create new tag */}
<div className="tag-create-form">
<h4>Create New Tag</h4>
<h4>{t('tagsView.create.title')}</h4>
<div className="tag-form-row">
<input
type="text"
placeholder="Tag name"
placeholder={t('tagsView.tagNamePlaceholder')}
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateTag()}
@@ -412,19 +423,19 @@ export const TagsView: React.FC = () => {
type="color"
value={newTagColor || '#808080'}
onChange={(e) => setNewTagColor(e.target.value)}
title="Choose color"
title={t('tagsView.chooseColor')}
/>
{newTagColor && (
<button
className="clear-color"
onClick={() => setNewTagColor('')}
title="Remove color"
title={t('tagsView.removeColor')}
>
</button>
)}
</div>
<button onClick={handleCreateTag} className="primary">Create</button>
<button onClick={handleCreateTag} className="primary">{t('tagsView.create.action')}</button>
</div>
<div className="color-presets">
{COLOR_PRESETS.map(color => (
@@ -442,14 +453,14 @@ export const TagsView: React.FC = () => {
{/* Selected tag editor */}
{selectedTagObjects.length === 1 && (
<div className="tag-edit-form">
<h4>Edit Tag: {selectedTagObjects[0].name}</h4>
<h4>{t('tagsView.edit.title', { name: selectedTagObjects[0].name })}</h4>
{editingTagId === selectedTagObjects[0].id ? (
<div className="tag-form-row">
<input
type="text"
value={editTagName}
onChange={(e) => setEditTagName(e.target.value)}
placeholder="Tag name"
placeholder={t('tagsView.tagNamePlaceholder')}
/>
<div className="color-picker-group">
<input
@@ -461,14 +472,14 @@ export const TagsView: React.FC = () => {
<button
className="clear-color"
onClick={() => setEditTagColor('')}
title="Remove color"
title={t('tagsView.removeColor')}
>
</button>
)}
</div>
<button onClick={handleSaveEdit} className="primary">Save</button>
<button onClick={() => setEditingTagId(null)}>Cancel</button>
<button onClick={handleSaveEdit} className="primary">{t('common.save')}</button>
<button onClick={() => setEditingTagId(null)}>{t('common.cancel')}</button>
</div>
) : (
<div className="tag-form-row">
@@ -480,7 +491,7 @@ export const TagsView: React.FC = () => {
{selectedTagObjects[0].name}
</span>
<button onClick={() => handleStartEdit(selectedTagObjects[0])}>
Edit
{t('tagsView.edit.action')}
</button>
<button
className="danger"
@@ -489,7 +500,7 @@ export const TagsView: React.FC = () => {
tagName: selectedTagObjects[0].name
})}
>
Delete
{t('tagsView.deleteAction')}
</button>
</div>
)}
@@ -500,20 +511,20 @@ export const TagsView: React.FC = () => {
{/* Merge Tags Section */}
<SectionHeader
id="tags-section-merge"
title="Merge Tags"
description="Select multiple tags above, then merge them into a single tag. All posts will be updated."
title={t('tagsView.merge.title')}
description={t('tagsView.merge.description')}
>
{selectedTags.length < 2 ? (
<p className="text-muted">Select 2 or more tags from the cloud above to merge them.</p>
<p className="text-muted">{t('tagsView.merge.selectAtLeastTwo')}</p>
) : (
<div className="merge-form">
<p>Merge <strong>{selectedTags.length}</strong> tags into:</p>
<p>{t('tagsView.merge.countInto', { count: selectedTags.length })}</p>
<div className="tag-form-row">
<select
value={mergeTargetName}
onChange={(e) => setMergeTargetName(e.target.value)}
>
<option value="">Select target tag...</option>
<option value="">{t('tagsView.merge.selectTarget')}</option>
{selectedTags.map(name => (
<option key={name} value={name}>{name}</option>
))}
@@ -530,11 +541,13 @@ export const TagsView: React.FC = () => {
}
}}
>
Merge Tags
{t('tagsView.merge.action')}
</button>
</div>
<p className="text-muted text-small">
Tags to be deleted: {selectedTags.filter(n => n !== mergeTargetName).join(', ') || '(none)'}
{t('tagsView.merge.tagsToDelete', {
tags: selectedTags.filter(n => n !== mergeTargetName).join(', ') || t('tagsView.none'),
})}
</p>
</div>
)}
@@ -543,11 +556,11 @@ export const TagsView: React.FC = () => {
{/* Sync Section */}
<SectionHeader
id="tags-section-sync"
title="Sync Tags"
description="Discover tags that exist in posts but not in the tag database."
title={t('tagsView.sync.title')}
description={t('tagsView.sync.description')}
>
<button onClick={handleSyncFromPosts}>
Sync Tags from Posts
{t('tagsView.sync.action')}
</button>
</SectionHeader>
</div>
@@ -555,9 +568,10 @@ export const TagsView: React.FC = () => {
{/* Confirm Dialogs */}
<ConfirmDialog
isOpen={!!deleteConfirm}
title="Delete Tag"
message={`Are you sure you want to delete the tag "${deleteConfirm?.tagName}"? This will remove it from all posts. This action runs as a background task.`}
confirmText="Delete Tag"
title={t('tagsView.confirmDelete.title')}
message={t('tagsView.confirmDelete.message', { tagName: deleteConfirm?.tagName || '' })}
confirmText={t('tagsView.confirmDelete.action')}
cancelText={t('common.cancel')}
isDestructive
onConfirm={handleDeleteTag}
onCancel={() => setDeleteConfirm(null)}
@@ -565,9 +579,13 @@ export const TagsView: React.FC = () => {
<ConfirmDialog
isOpen={!!mergeConfirm}
title="Merge Tags"
message={`Are you sure you want to merge ${mergeConfirm?.sourceNames.length} tag(s) into "${mergeConfirm?.targetName}"? The source tags will be deleted and all posts will be updated. This runs as a background task.`}
confirmText="Merge Tags"
title={t('tagsView.confirmMerge.title')}
message={t('tagsView.confirmMerge.message', {
count: mergeConfirm?.sourceNames.length || 0,
target: mergeConfirm?.targetName || '',
})}
confirmText={t('tagsView.confirmMerge.action')}
cancelText={t('common.cancel')}
onConfirm={handleMergeTags}
onCancel={() => setMergeConfirm(null)}
/>