chore: translations were still missing
This commit is contained in:
@@ -20,8 +20,17 @@ import { DocumentationView } from '../DocumentationView/DocumentationView';
|
||||
import { AutoSaveManager, getContrastColor } from '../../utils';
|
||||
import { InsertModal } from '../InsertModal';
|
||||
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './Editor.css';
|
||||
|
||||
const UI_DATE_LOCALE: Record<string, string> = {
|
||||
en: 'en-US',
|
||||
de: 'de-DE',
|
||||
fr: 'fr-FR',
|
||||
it: 'it-IT',
|
||||
es: 'es-ES',
|
||||
};
|
||||
|
||||
/** Get display name for media: prefer title over originalName */
|
||||
function getMediaDisplayName(media: { title?: string; originalName: string }): string {
|
||||
return media.title || media.originalName;
|
||||
@@ -121,6 +130,7 @@ interface PostEditorProps {
|
||||
}
|
||||
|
||||
export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
const { t: tr, language } = useI18n();
|
||||
const {
|
||||
updatePost,
|
||||
markDirty,
|
||||
@@ -653,7 +663,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
<div className="editor">
|
||||
<div className="editor-empty">
|
||||
<div className="welcome-content">
|
||||
<p className="text-muted">Loading post...</p>
|
||||
<p className="text-muted">{tr('editor.loadingPost')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -665,36 +675,36 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
<div className="editor-header">
|
||||
<div className="editor-tabs">
|
||||
<div className={`editor-tab active ${isDirty ? 'dirty' : ''}`}>
|
||||
<span className="editor-tab-title">{title || 'Untitled'}</span>
|
||||
{isDirty && <span className="editor-tab-dirty" title="Unsaved changes (auto-saves on switch)">●</span>}
|
||||
<span className="editor-tab-title">{title || tr('editor.untitled')}</span>
|
||||
{isDirty && <span className="editor-tab-dirty" title={tr('editor.unsavedChanges')}>●</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-actions">
|
||||
<span className={`status-badge status-${post.status}`}>
|
||||
{post.status}
|
||||
</span>
|
||||
{isSaving && <span className="auto-save-indicator">Saving...</span>}
|
||||
{isSaving && <span className="auto-save-indicator">{tr('editor.saving')}</span>}
|
||||
{post.status === 'draft' && (
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
className="success"
|
||||
title="Save and make this post public"
|
||||
title={tr('editor.publishTitle')}
|
||||
>
|
||||
Publish
|
||||
{tr('editor.publish')}
|
||||
</button>
|
||||
)}
|
||||
{post.status === 'draft' && (
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="secondary danger"
|
||||
title={hasPublishedVersion ? "Revert to last published version" : "Delete this draft permanently"}
|
||||
title={hasPublishedVersion ? tr('editor.discardChangesTitle') : tr('editor.discardDraftTitle')}
|
||||
>
|
||||
{hasPublishedVersion ? 'Discard Changes' : 'Discard Draft'}
|
||||
{hasPublishedVersion ? tr('editor.discardChanges') : tr('editor.discardDraft')}
|
||||
</button>
|
||||
)}
|
||||
{post.status === 'published' && (
|
||||
<button onClick={handleDelete} className="secondary danger" title="Delete this post permanently">
|
||||
Delete
|
||||
<button onClick={handleDelete} className="secondary danger" title={tr('editor.deleteTitle')}>
|
||||
{tr('editor.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -704,34 +714,34 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
<div className="editor-header-row">
|
||||
<div className="editor-meta">
|
||||
<div className="editor-field">
|
||||
<label>Title</label>
|
||||
<label>{tr('editor.field.title')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Untitled"
|
||||
placeholder={tr('editor.untitled')}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>Tags</label>
|
||||
<label>{tr('editor.field.tags')}</label>
|
||||
<TagInput
|
||||
value={tags}
|
||||
onChange={setTags}
|
||||
placeholder="Add tags..."
|
||||
placeholder={tr('editor.placeholder.tags')}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>Author</label>
|
||||
<label>{tr('editor.field.author')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
placeholder="Author name"
|
||||
placeholder={tr('editor.placeholder.author')}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field-row">
|
||||
<div className="editor-field">
|
||||
<label>Slug</label>
|
||||
<label>{tr('editor.field.slug')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={post.slug}
|
||||
@@ -740,13 +750,13 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>Categories</label>
|
||||
<label>{tr('editor.field.categories')}</label>
|
||||
<TagInput
|
||||
value={selectedCategories}
|
||||
onChange={(categories) => {
|
||||
setSelectedCategories(categories.length > 0 ? categories : ['article']);
|
||||
}}
|
||||
placeholder="Add categories..."
|
||||
placeholder={tr('editor.placeholder.categories')}
|
||||
mode="category"
|
||||
/>
|
||||
</div>
|
||||
@@ -767,30 +777,30 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
<div className="editor-body">
|
||||
<div className="editor-toolbar">
|
||||
<div className="editor-toolbar-left">
|
||||
<label>Content</label>
|
||||
<label>{tr('editor.field.content')}</label>
|
||||
</div>
|
||||
<div className="editor-toolbar-center">
|
||||
<div className="editor-mode-toggle">
|
||||
<button
|
||||
className={editorMode === 'wysiwyg' ? 'active' : ''}
|
||||
onClick={() => handleEditorModeChange('wysiwyg')}
|
||||
title="Visual editor"
|
||||
title={tr('editor.mode.visualTitle')}
|
||||
>
|
||||
Visual
|
||||
{tr('editor.mode.visual')}
|
||||
</button>
|
||||
<button
|
||||
className={editorMode === 'markdown' ? 'active' : ''}
|
||||
onClick={() => handleEditorModeChange('markdown')}
|
||||
title="Markdown source"
|
||||
title={tr('editor.mode.markdownTitle')}
|
||||
>
|
||||
Markdown
|
||||
{tr('settings.editor.mode.markdown')}
|
||||
</button>
|
||||
<button
|
||||
className={editorMode === 'preview' ? 'active' : ''}
|
||||
onClick={() => handleEditorModeChange('preview')}
|
||||
title="Read-only preview"
|
||||
title={tr('editor.mode.previewTitle')}
|
||||
>
|
||||
Preview
|
||||
{tr('settings.editor.mode.preview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -799,7 +809,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
<button
|
||||
className="gallery-button"
|
||||
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
|
||||
title={`View ${images.length} image(s)`}
|
||||
title={tr('editor.galleryTitle', { count: images.length })}
|
||||
>
|
||||
📷 {images.length}
|
||||
</button>
|
||||
@@ -809,14 +819,14 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
<button
|
||||
className="insert-post-link-button"
|
||||
onClick={() => setShowPostSearch(true)}
|
||||
title="Link to post (Ctrl+K)"
|
||||
title={tr('editor.insertPostLinkTitle')}
|
||||
>
|
||||
📝
|
||||
</button>
|
||||
<button
|
||||
className="insert-media-button"
|
||||
onClick={() => setShowMediaSearch(true)}
|
||||
title="Insert image from media library"
|
||||
title={tr('editor.insertMediaTitle')}
|
||||
>
|
||||
🖼️
|
||||
</button>
|
||||
@@ -829,7 +839,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
<MilkdownEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Start writing..."
|
||||
placeholder={tr('editor.placeholder.startWriting')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -866,10 +876,10 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
<iframe
|
||||
className="editor-preview-frame"
|
||||
src={previewUrl}
|
||||
title="Post preview"
|
||||
title={tr('editor.previewFrameTitle')}
|
||||
/>
|
||||
) : (
|
||||
<div className="editor-preview-loading">Loading preview...</div>
|
||||
<div className="editor-preview-loading">{tr('editor.previewLoading')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -886,14 +896,14 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
|
||||
<div className="editor-footer">
|
||||
<span className="text-muted text-small">
|
||||
Created: {new Date(post.createdAt).toLocaleString()}
|
||||
{tr('editor.footer.created')}: {new Date(post.createdAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
|
||||
</span>
|
||||
<span className="text-muted text-small">
|
||||
Updated: {new Date(post.updatedAt).toLocaleString()}
|
||||
{tr('editor.footer.updated')}: {new Date(post.updatedAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
|
||||
</span>
|
||||
{post.publishedAt && (
|
||||
<span className="text-muted text-small">
|
||||
Published: {new Date(post.publishedAt).toLocaleString()}
|
||||
{tr('editor.footer.published')}: {new Date(post.publishedAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -338,18 +338,18 @@ export const SettingsView: React.FC = () => {
|
||||
<SettingSection
|
||||
id="settings-section-project"
|
||||
title={t('settings.project.title')}
|
||||
description="General settings for the active blog project."
|
||||
description={t('settings.project.descriptionGeneral')}
|
||||
hidden={!sectionHasMatches(projectKeywords)}
|
||||
>
|
||||
<SettingRow
|
||||
id="project-name"
|
||||
label="Project Name"
|
||||
description="The display name of your blog project."
|
||||
label={t('settings.project.nameLabel')}
|
||||
description={t('settings.project.nameDescription')}
|
||||
>
|
||||
<input
|
||||
id="project-name"
|
||||
type="text"
|
||||
placeholder="My Blog"
|
||||
placeholder={t('settings.project.namePlaceholder')}
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
/>
|
||||
@@ -357,12 +357,12 @@ export const SettingsView: React.FC = () => {
|
||||
|
||||
<SettingRow
|
||||
id="project-description"
|
||||
label="Description"
|
||||
description="A short description of your blog. This can be used in templates and metadata."
|
||||
label={t('settings.project.descriptionLabel')}
|
||||
description={t('settings.project.descriptionDescription')}
|
||||
>
|
||||
<textarea
|
||||
id="project-description"
|
||||
placeholder="A blog about..."
|
||||
placeholder={t('settings.project.descriptionPlaceholder')}
|
||||
value={projectDescription}
|
||||
onChange={(e) => setProjectDescription(e.target.value)}
|
||||
rows={3}
|
||||
@@ -371,14 +371,14 @@ export const SettingsView: React.FC = () => {
|
||||
|
||||
<SettingRow
|
||||
id="project-datapath"
|
||||
label="Project Data Path"
|
||||
description={`Custom folder for storing posts, media, and metadata. Leave empty to use the default location: ${defaultProjectPath}`}
|
||||
label={t('settings.project.dataPathLabel')}
|
||||
description={t('settings.project.dataPathDescription', { path: defaultProjectPath })}
|
||||
>
|
||||
<div className="setting-input-group">
|
||||
<input
|
||||
id="project-datapath"
|
||||
type="text"
|
||||
placeholder={defaultProjectPath || 'Default location'}
|
||||
placeholder={defaultProjectPath || t('settings.project.defaultLocation')}
|
||||
value={projectDataPath}
|
||||
onChange={(e) => setProjectDataPath(e.target.value)}
|
||||
/>
|
||||
@@ -395,13 +395,13 @@ export const SettingsView: React.FC = () => {
|
||||
|
||||
<SettingRow
|
||||
id="project-public-url"
|
||||
label="Public URL"
|
||||
description="The public base URL of your published blog (used for sitemap generation)."
|
||||
label={t('settings.project.publicUrlLabel')}
|
||||
description={t('settings.project.publicUrlDescription')}
|
||||
>
|
||||
<input
|
||||
id="project-public-url"
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
placeholder={t('settings.project.publicUrlPlaceholder')}
|
||||
value={projectPublicUrl}
|
||||
onChange={(e) => setProjectPublicUrl(e.target.value)}
|
||||
/>
|
||||
@@ -409,8 +409,8 @@ export const SettingsView: React.FC = () => {
|
||||
|
||||
<SettingRow
|
||||
id="project-language"
|
||||
label="Main Language"
|
||||
description="The primary language for your blog content. AI-generated titles, alt text, and captions will use this language."
|
||||
label={t('settings.project.mainLanguageLabel')}
|
||||
description={t('settings.project.mainLanguageDescription')}
|
||||
>
|
||||
<select
|
||||
id="project-language"
|
||||
@@ -442,13 +442,13 @@ export const SettingsView: React.FC = () => {
|
||||
|
||||
<SettingRow
|
||||
id="project-author"
|
||||
label="Default Author"
|
||||
description="The default author name for new posts and media. Can be overridden per item."
|
||||
label={t('settings.project.defaultAuthorLabel')}
|
||||
description={t('settings.project.defaultAuthorDescription')}
|
||||
>
|
||||
<input
|
||||
id="project-author"
|
||||
type="text"
|
||||
placeholder="Author Name"
|
||||
placeholder={t('settings.project.defaultAuthorPlaceholder')}
|
||||
value={projectDefaultAuthor}
|
||||
onChange={(e) => setProjectDefaultAuthor(e.target.value)}
|
||||
/>
|
||||
@@ -456,8 +456,8 @@ export const SettingsView: React.FC = () => {
|
||||
|
||||
<SettingRow
|
||||
id="project-max-posts-per-page"
|
||||
label="Max Posts Per Page"
|
||||
description="Maximum number of posts shown per preview route page."
|
||||
label={t('settings.project.maxPostsPerPageLabel')}
|
||||
description={t('settings.project.maxPostsPerPageDescription')}
|
||||
>
|
||||
<input
|
||||
id="project-max-posts-per-page"
|
||||
@@ -478,7 +478,7 @@ export const SettingsView: React.FC = () => {
|
||||
|
||||
<div className="setting-actions">
|
||||
<button className="primary" onClick={handleSaveProject}>
|
||||
Save Project Settings
|
||||
{t('settings.project.saveButton')}
|
||||
</button>
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { showToast } from '../Toast';
|
||||
import { getContrastColor, groupPostsByStatus } from '../../utils';
|
||||
import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
|
||||
import { GitSidebar } from '../GitSidebar/GitSidebar';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './Sidebar.css';
|
||||
|
||||
/** Get display name for media: title (truncated to 60 chars) or fallback to filename */
|
||||
@@ -23,9 +24,17 @@ interface TagData {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const UI_DATE_LOCALE: Record<string, string> = {
|
||||
en: 'en-US',
|
||||
de: 'de-DE',
|
||||
fr: 'fr-FR',
|
||||
it: 'it-IT',
|
||||
es: 'es-ES',
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string, locale: string = 'en-US') => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
return date.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
@@ -86,6 +95,7 @@ interface CalendarViewProps {
|
||||
}
|
||||
|
||||
const CalendarView: React.FC<CalendarViewProps> = ({ onDateSelect, selectedYear, selectedMonth }) => {
|
||||
const { t } = useI18n();
|
||||
const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]);
|
||||
const [expandedYear, setExpandedYear] = useState<number | null>(null);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
@@ -118,12 +128,12 @@ const CalendarView: React.FC<CalendarViewProps> = ({ onDateSelect, selectedYear,
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<span className="collapse-icon">{isCollapsed ? '▶' : '▼'}</span>
|
||||
<span>ARCHIVE</span>
|
||||
<span>{t('sidebar.archive')}</span>
|
||||
{(selectedYear || selectedMonth !== undefined) && (
|
||||
<button
|
||||
className="clear-filter"
|
||||
onClick={(e) => { e.stopPropagation(); onDateSelect(0); }}
|
||||
title="Clear filter"
|
||||
title={t('sidebar.clearFilter')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -163,7 +173,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ onDateSelect, selectedYear,
|
||||
</div>
|
||||
))}
|
||||
{years.length === 0 && (
|
||||
<div className="calendar-empty">No posts yet</div>
|
||||
<div className="calendar-empty">{t('sidebar.noPostsYet')}</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
@@ -189,6 +199,7 @@ const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
onTagSelect,
|
||||
onCategorySelect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [tagsCollapsed, setTagsCollapsed] = useState(true);
|
||||
const [categoriesCollapsed, setCategoriesCollapsed] = useState(true);
|
||||
|
||||
@@ -201,12 +212,12 @@ const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
onClick={() => setTagsCollapsed(!tagsCollapsed)}
|
||||
>
|
||||
<span className="collapse-icon">{tagsCollapsed ? '▶' : '▼'}</span>
|
||||
<span>TAGS</span>
|
||||
<span>{t('sidebar.tags')}</span>
|
||||
{selectedTags.length > 0 && (
|
||||
<button
|
||||
className="clear-filter"
|
||||
onClick={(e) => { e.stopPropagation(); onTagSelect([]); }}
|
||||
title="Clear tags"
|
||||
title={t('sidebar.clearTags')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -250,12 +261,12 @@ const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
onClick={() => setCategoriesCollapsed(!categoriesCollapsed)}
|
||||
>
|
||||
<span className="collapse-icon">{categoriesCollapsed ? '▶' : '▼'}</span>
|
||||
<span>CATEGORIES</span>
|
||||
<span>{t('sidebar.categories')}</span>
|
||||
{selectedCategories.length > 0 && (
|
||||
<button
|
||||
className="clear-filter"
|
||||
onClick={(e) => { e.stopPropagation(); onCategorySelect([]); }}
|
||||
title="Clear categories"
|
||||
title={t('sidebar.clearCategories')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -292,6 +303,7 @@ interface MediaCalendarViewProps {
|
||||
}
|
||||
|
||||
const MediaCalendarView: React.FC<MediaCalendarViewProps> = ({ onDateSelect, selectedYear, selectedMonth }) => {
|
||||
const { t } = useI18n();
|
||||
const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]);
|
||||
const [expandedYear, setExpandedYear] = useState<number | null>(null);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
@@ -323,12 +335,12 @@ const MediaCalendarView: React.FC<MediaCalendarViewProps> = ({ onDateSelect, sel
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<span className="collapse-icon">{isCollapsed ? '▶' : '▼'}</span>
|
||||
<span>ARCHIVE</span>
|
||||
<span>{t('sidebar.archive')}</span>
|
||||
{(selectedYear || selectedMonth !== undefined) && (
|
||||
<button
|
||||
className="clear-filter"
|
||||
onClick={(e) => { e.stopPropagation(); onDateSelect(0); }}
|
||||
title="Clear filter"
|
||||
title={t('sidebar.clearFilter')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -368,7 +380,7 @@ const MediaCalendarView: React.FC<MediaCalendarViewProps> = ({ onDateSelect, sel
|
||||
</div>
|
||||
))}
|
||||
{years.length === 0 && (
|
||||
<div className="calendar-empty">No media yet</div>
|
||||
<div className="calendar-empty">{t('sidebar.noMediaYet')}</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
@@ -389,6 +401,7 @@ const MediaFilterPanel: React.FC<MediaFilterPanelProps> = ({
|
||||
selectedTags,
|
||||
onTagSelect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [tagsCollapsed, setTagsCollapsed] = useState(true);
|
||||
|
||||
return (
|
||||
@@ -400,12 +413,12 @@ const MediaFilterPanel: React.FC<MediaFilterPanelProps> = ({
|
||||
onClick={() => setTagsCollapsed(!tagsCollapsed)}
|
||||
>
|
||||
<span className="collapse-icon">{tagsCollapsed ? '▶' : '▼'}</span>
|
||||
<span>TAGS</span>
|
||||
<span>{t('sidebar.tags')}</span>
|
||||
{selectedTags.length > 0 && (
|
||||
<button
|
||||
className="clear-filter"
|
||||
onClick={(e) => { e.stopPropagation(); onTagSelect([]); }}
|
||||
title="Clear tags"
|
||||
title={t('sidebar.clearTags')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -448,9 +461,11 @@ const MediaFilterPanel: React.FC<MediaFilterPanelProps> = ({
|
||||
|
||||
interface SearchBoxProps {
|
||||
onSearch: (query: string) => void;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
|
||||
const SearchBox: React.FC<SearchBoxProps> = ({ onSearch, placeholder }) => {
|
||||
const { t } = useI18n();
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -462,17 +477,17 @@ const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
|
||||
<form className="search-box" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search posts..."
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit" title="Search">
|
||||
<button type="submit" title={t('sidebar.search')}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M15.7 14.3l-4.2-4.2c-.2-.2-.5-.3-.8-.3.9-1.1 1.5-2.5 1.5-4C12.2 2.6 9.6 0 6.4 0S.6 2.6.6 5.8s2.6 5.8 5.8 5.8c1.5 0 2.9-.5 4-1.4 0 .3.1.6.3.8l4.2 4.2c.2.2.5.3.7.3s.5-.1.7-.3c.4-.4.4-1 0-1.4zm-9.3-4c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{query && (
|
||||
<button type="button" className="clear-search" onClick={() => { setQuery(''); onSearch(''); }} title="Clear">
|
||||
<button type="button" className="clear-search" onClick={() => { setQuery(''); onSearch(''); }} title={t('common.clear')}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
@@ -488,6 +503,8 @@ interface PostsListProps {
|
||||
}
|
||||
|
||||
const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
const { t, language } = useI18n();
|
||||
const uiLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
|
||||
const { posts, hasMorePosts, totalPosts, appendPosts, openTab, activeTabId } = useAppStore();
|
||||
const isPagesMode = mode === 'pages';
|
||||
const postSubset = useMemo(() => applyPageFilter(posts, isPagesMode), [posts, isPagesMode]);
|
||||
@@ -513,7 +530,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
const [tags, categories, allTagsData] = await Promise.all([
|
||||
window.electronAPI?.posts.getTags(),
|
||||
window.electronAPI?.posts.getCategories(),
|
||||
window.electronAPI?.tags.getAll(),
|
||||
window.electronAPI?.tags?.getAll?.(),
|
||||
]);
|
||||
if (tags) setAvailableTags(tags as string[]);
|
||||
if (categories) {
|
||||
@@ -588,7 +605,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
showToast.error('Search failed');
|
||||
showToast.error(t('sidebar.search'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -793,18 +810,18 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
<div className="sidebar-content">
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-header">
|
||||
<span>{isPagesMode ? 'PAGES' : 'POSTS'}</span>
|
||||
<span>{(isPagesMode ? t('activity.pages') : t('activity.posts')).toUpperCase()}</span>
|
||||
<div className="sidebar-actions">
|
||||
<button
|
||||
className={`sidebar-action ${showFilters ? 'active' : ''}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
title="Toggle Filters"
|
||||
title={t('sidebar.toggleFilters')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button className="sidebar-action" onClick={handleCreatePost} title="New Post">
|
||||
<button className="sidebar-action" onClick={handleCreatePost} title={t('sidebar.newPost')}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
|
||||
</svg>
|
||||
@@ -813,7 +830,10 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchBox onSearch={handleSearch} />
|
||||
<SearchBox
|
||||
onSearch={handleSearch}
|
||||
placeholder={isPagesMode ? t('sidebar.searchPagesPlaceholder') : t('sidebar.searchPostsPlaceholder')}
|
||||
/>
|
||||
|
||||
{showFilters && (
|
||||
<>
|
||||
@@ -837,11 +857,15 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
{hasActiveFilters && (
|
||||
<div className="filter-status">
|
||||
<span>
|
||||
{groupedPosts.published.length + groupedPosts.archived.length} result{groupedPosts.published.length + groupedPosts.archived.length !== 1 ? 's' : ''}
|
||||
{searchQuery && ` for "${searchQuery}"`}
|
||||
{searchQuery
|
||||
? t('sidebar.resultsFor', {
|
||||
count: groupedPosts.published.length + groupedPosts.archived.length,
|
||||
query: searchQuery,
|
||||
})
|
||||
: t('sidebar.results', { count: groupedPosts.published.length + groupedPosts.archived.length })}
|
||||
</span>
|
||||
<button onClick={clearAllFilters} title="Clear all filters">
|
||||
Clear filters
|
||||
<button onClick={clearAllFilters} title={t('sidebar.clearFilters')}>
|
||||
{t('sidebar.clearFilters')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -850,7 +874,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-title">
|
||||
<span className="section-icon status-draft">●</span>
|
||||
Drafts ({groupedPosts.draft.length})
|
||||
{t('sidebar.drafts')} ({groupedPosts.draft.length})
|
||||
</div>
|
||||
<div className="sidebar-list">
|
||||
{groupedPosts.draft.map(post => {
|
||||
@@ -864,8 +888,8 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">{post.title || 'Untitled'}</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
|
||||
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.updatedAt, uiLocale)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -878,7 +902,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-title">
|
||||
<span className="section-icon status-published">●</span>
|
||||
Published ({groupedPosts.published.length})
|
||||
{t('sidebar.published')} ({groupedPosts.published.length})
|
||||
</div>
|
||||
<div className="sidebar-list">
|
||||
{groupedPosts.published.map(post => {
|
||||
@@ -892,8 +916,8 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">{post.title || 'Untitled'}</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div>
|
||||
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt, uiLocale)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -906,7 +930,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-title">
|
||||
<span className="section-icon status-archived">●</span>
|
||||
Archived ({groupedPosts.archived.length})
|
||||
{t('sidebar.archived')} ({groupedPosts.archived.length})
|
||||
</div>
|
||||
<div className="sidebar-list">
|
||||
{groupedPosts.archived.map(post => {
|
||||
@@ -920,8 +944,8 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">{post.title || 'Untitled'}</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
|
||||
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.updatedAt, uiLocale)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -932,15 +956,15 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
|
||||
{baseDisplayPosts.length === 0 && !isFiltered && (
|
||||
<div className="sidebar-empty">
|
||||
<p>{isPagesMode ? 'No pages yet' : 'No posts yet'}</p>
|
||||
<button onClick={handleCreatePost}>Create your first post</button>
|
||||
<p>{isPagesMode ? t('sidebar.noPagesYet') : t('sidebar.noPostsYet')}</p>
|
||||
<button onClick={handleCreatePost}>{t('sidebar.createFirstPost')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedPosts.published.length === 0 && groupedPosts.archived.length === 0 && isFiltered && (
|
||||
<div className="sidebar-empty">
|
||||
<p>No matching posts</p>
|
||||
<button onClick={clearAllFilters}>Clear filters</button>
|
||||
<p>{t('sidebar.noMatchingPosts')}</p>
|
||||
<button onClick={clearAllFilters}>{t('sidebar.clearFilters')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -952,7 +976,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
disabled={isLoadingMore}
|
||||
className="load-more-button"
|
||||
>
|
||||
{isLoadingMore ? 'Loading...' : `Load more (${posts.length} of ${totalPosts})`}
|
||||
{isLoadingMore ? t('sidebar.loading') : t('sidebar.loadMore', { loaded: posts.length, total: totalPosts })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -961,6 +985,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
};
|
||||
|
||||
const MediaList: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { media, openTab, activeTabId } = useAppStore();
|
||||
|
||||
// Filter state
|
||||
@@ -979,7 +1004,7 @@ const MediaList: React.FC = () => {
|
||||
const loadTags = async () => {
|
||||
const [tags, allTagsData] = await Promise.all([
|
||||
window.electronAPI?.media.getTags(),
|
||||
window.electronAPI?.tags.getAll(),
|
||||
window.electronAPI?.tags?.getAll?.(),
|
||||
]);
|
||||
if (tags) setAvailableTags(tags as string[]);
|
||||
if (allTagsData) {
|
||||
@@ -1010,7 +1035,7 @@ const MediaList: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
showToast.error('Search failed');
|
||||
showToast.error(t('sidebar.search'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1097,18 +1122,18 @@ const MediaList: React.FC = () => {
|
||||
<div className="sidebar-content">
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-header">
|
||||
<span>MEDIA</span>
|
||||
<span>{t('activity.media').toUpperCase()}</span>
|
||||
<div className="sidebar-actions">
|
||||
<button
|
||||
className={`sidebar-action ${showFilters ? 'active' : ''}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
title="Toggle Filters"
|
||||
title={t('sidebar.toggleFilters')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button className="sidebar-action" onClick={handleImportMedia} title="Import Media">
|
||||
<button className="sidebar-action" onClick={handleImportMedia} title={t('sidebar.importMedia')}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
|
||||
</svg>
|
||||
@@ -1117,7 +1142,7 @@ const MediaList: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchBox onSearch={handleSearch} />
|
||||
<SearchBox onSearch={handleSearch} placeholder={t('sidebar.searchMediaPlaceholder')} />
|
||||
|
||||
{showFilters && (
|
||||
<>
|
||||
@@ -1138,11 +1163,12 @@ const MediaList: React.FC = () => {
|
||||
{hasActiveFilters && (
|
||||
<div className="filter-status">
|
||||
<span>
|
||||
{filteredDisplayMedia.length} result{filteredDisplayMedia.length !== 1 ? 's' : ''}
|
||||
{searchQuery && ` for "${searchQuery}"`}
|
||||
{searchQuery
|
||||
? t('sidebar.resultsFor', { count: filteredDisplayMedia.length, query: searchQuery })
|
||||
: t('sidebar.results', { count: filteredDisplayMedia.length })}
|
||||
</span>
|
||||
<button onClick={clearAllFilters} title="Clear all filters">
|
||||
Clear filters
|
||||
<button onClick={clearAllFilters} title={t('sidebar.clearFilters')}>
|
||||
{t('sidebar.clearFilters')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1184,8 +1210,8 @@ const MediaList: React.FC = () => {
|
||||
|
||||
{filteredDisplayMedia.length === 0 && (
|
||||
<div className="sidebar-empty">
|
||||
<p>No media files</p>
|
||||
<button onClick={handleImportMedia}>Import media</button>
|
||||
<p>{t('sidebar.noMediaFiles')}</p>
|
||||
<button onClick={handleImportMedia}>{t('sidebar.importMedia')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1196,6 +1222,7 @@ import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/Setti
|
||||
import { scrollToTagsSection, TagsCategory } from '../TagsView';
|
||||
|
||||
const TagsNav: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [activeSection, setActiveSection] = useState<TagsCategory | null>(null);
|
||||
|
||||
const handleNavClick = (category: TagsCategory) => {
|
||||
@@ -1207,7 +1234,7 @@ const TagsNav: React.FC = () => {
|
||||
<div className="sidebar-content settings-panel">
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-header">
|
||||
<span>TAGS</span>
|
||||
<span>{t('sidebar.tagsHeader').toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1217,21 +1244,21 @@ const TagsNav: React.FC = () => {
|
||||
onClick={() => handleNavClick('cloud')}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">☁️</span>
|
||||
<span>Tag Cloud</span>
|
||||
<span>{t('sidebar.tagCloud')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`settings-nav-entry ${activeSection === 'manage' ? 'active' : ''}`}
|
||||
onClick={() => handleNavClick('manage')}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">✏️</span>
|
||||
<span>Create & Edit</span>
|
||||
<span>{t('sidebar.createEdit')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`settings-nav-entry ${activeSection === 'merge' ? 'active' : ''}`}
|
||||
onClick={() => handleNavClick('merge')}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">🔀</span>
|
||||
<span>Merge Tags</span>
|
||||
<span>{t('sidebar.mergeTags')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1239,6 +1266,7 @@ const TagsNav: React.FC = () => {
|
||||
};
|
||||
|
||||
const SettingsNav: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { tabs, activeTabId, openTab } = useAppStore();
|
||||
const [activeSection, setActiveSection] = useState<SettingsCategory | null>(null);
|
||||
|
||||
@@ -1266,7 +1294,7 @@ const SettingsNav: React.FC = () => {
|
||||
<div className="sidebar-content settings-panel">
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-header">
|
||||
<span>SETTINGS</span>
|
||||
<span>{t('sidebar.settingsHeader').toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1276,49 +1304,49 @@ const SettingsNav: React.FC = () => {
|
||||
onClick={() => handleNavClick('project')}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">📁</span>
|
||||
<span>Project</span>
|
||||
<span>{t('sidebar.nav.project')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`settings-nav-entry ${activeSection === 'editor' ? 'active' : ''}`}
|
||||
onClick={() => handleNavClick('editor')}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">📝</span>
|
||||
<span>Editor</span>
|
||||
<span>{t('sidebar.nav.editor')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`settings-nav-entry ${activeSection === 'content' ? 'active' : ''}`}
|
||||
onClick={() => handleNavClick('content')}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">📋</span>
|
||||
<span>Content</span>
|
||||
<span>{t('sidebar.nav.content')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`settings-nav-entry ${activeSection === 'ai' ? 'active' : ''}`}
|
||||
onClick={() => handleNavClick('ai')}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">🤖</span>
|
||||
<span>AI Assistant</span>
|
||||
<span>{t('sidebar.nav.ai')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`settings-nav-entry ${activeSection === 'publishing' ? 'active' : ''}`}
|
||||
onClick={() => handleNavClick('publishing')}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">🚀</span>
|
||||
<span>Publishing</span>
|
||||
<span>{t('sidebar.nav.publishing')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`settings-nav-entry ${activeSection === 'data' ? 'active' : ''}`}
|
||||
onClick={() => handleNavClick('data')}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">🗄️</span>
|
||||
<span>Data</span>
|
||||
<span>{t('sidebar.nav.data')}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`settings-nav-entry ${isStyleTabActive ? 'active' : ''}`}
|
||||
onClick={handleStyleClick}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">🎨</span>
|
||||
<span>Style</span>
|
||||
<span>{t('sidebar.nav.style')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,3 +94,21 @@
|
||||
border: 1px solid var(--vscode-statusBar-border, transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.status-bar-item.language-badge {
|
||||
border: 1px solid var(--vscode-statusBar-border, transparent);
|
||||
border-radius: 3px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-bar-language-select {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.status-bar-language-select:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,19 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { ProjectSelector } from '../ProjectSelector';
|
||||
import { getRendererPicoTheme } from '../../utils/picoTheme';
|
||||
import { useI18n, type UiLanguage } from '../../i18n';
|
||||
import './StatusBar.css';
|
||||
|
||||
const UI_LANGUAGE_LABEL_KEYS: Record<UiLanguage, string> = {
|
||||
en: 'settings.language.english',
|
||||
de: 'settings.language.german',
|
||||
fr: 'settings.language.french',
|
||||
it: 'settings.language.italian',
|
||||
es: 'settings.language.spanish',
|
||||
};
|
||||
|
||||
export const StatusBar: React.FC = () => {
|
||||
const { language, setLanguage, supportedLanguages, t } = useI18n();
|
||||
const {
|
||||
media,
|
||||
tasks,
|
||||
@@ -68,6 +78,23 @@ export const StatusBar: React.FC = () => {
|
||||
<span>Theme: {activeTheme}</span>
|
||||
</div>
|
||||
|
||||
<div className="status-bar-item language-badge">
|
||||
<span>UI</span>
|
||||
<select
|
||||
className="status-bar-language-select"
|
||||
data-testid="statusbar-language-select"
|
||||
aria-label="UI language"
|
||||
value={language}
|
||||
onChange={(event) => setLanguage(event.target.value as UiLanguage)}
|
||||
>
|
||||
{supportedLanguages.map((supportedLanguage) => (
|
||||
<option key={supportedLanguage} value={supportedLanguage}>
|
||||
{t(UI_LANGUAGE_LABEL_KEYS[supportedLanguage])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* App Name */}
|
||||
<div className="status-bar-item brand">
|
||||
<span>bDS</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import enJson from './locales/en.json';
|
||||
import deJson from './locales/de.json';
|
||||
import frJson from './locales/fr.json';
|
||||
@@ -7,6 +7,10 @@ import esJson from './locales/es.json';
|
||||
|
||||
export type UiLanguage = 'en' | 'de' | 'fr' | 'it' | 'es';
|
||||
|
||||
export const UI_LANGUAGE_STORAGE_KEY = 'bds-ui-language';
|
||||
|
||||
export const SUPPORTED_UI_LANGUAGES: UiLanguage[] = ['en', 'de', 'fr', 'it', 'es'];
|
||||
|
||||
type TranslationTable = Record<string, string>;
|
||||
|
||||
const en = enJson as TranslationTable;
|
||||
@@ -58,29 +62,59 @@ export function translateUi(
|
||||
export interface I18nContextValue {
|
||||
language: UiLanguage;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
setLanguage: (language: UiLanguage) => void;
|
||||
supportedLanguages: UiLanguage[];
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue>({
|
||||
language: 'en',
|
||||
t: (key, params) => translateUi('en', key, params),
|
||||
setLanguage: () => {},
|
||||
supportedLanguages: SUPPORTED_UI_LANGUAGES,
|
||||
});
|
||||
|
||||
export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [language, setLanguage] = useState<UiLanguage>('en');
|
||||
const [language, setLanguageState] = useState<UiLanguage>('en');
|
||||
|
||||
const setLanguage = useCallback((nextLanguage: UiLanguage) => {
|
||||
const normalized = resolveSupportedUiLanguage(nextLanguage);
|
||||
setLanguageState(normalized);
|
||||
try {
|
||||
localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, normalized);
|
||||
} catch {
|
||||
// Ignore storage errors and keep in-memory language state.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const persistedLanguage = (() => {
|
||||
try {
|
||||
const value = localStorage.getItem(UI_LANGUAGE_STORAGE_KEY);
|
||||
return value ? resolveSupportedUiLanguage(value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (persistedLanguage) {
|
||||
setLanguageState(persistedLanguage);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const detectLanguage = async () => {
|
||||
try {
|
||||
const systemLocale = await window.electronAPI?.app.getSystemLanguage?.();
|
||||
const locale = systemLocale || navigator.language;
|
||||
if (!cancelled) {
|
||||
setLanguage(resolveUiLanguageFromSystemLocale(locale));
|
||||
setLanguageState(resolveUiLanguageFromSystemLocale(locale));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLanguage(resolveUiLanguageFromSystemLocale(navigator.language));
|
||||
setLanguageState(resolveUiLanguageFromSystemLocale(navigator.language));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -96,8 +130,10 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
() => ({
|
||||
language,
|
||||
t: (key, params) => translateUi(language, key, params),
|
||||
setLanguage,
|
||||
supportedLanguages: SUPPORTED_UI_LANGUAGES,
|
||||
}),
|
||||
[language]
|
||||
[language, setLanguage]
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
|
||||
@@ -316,5 +316,99 @@
|
||||
"metadataDiff.sync.fileToDb.error": "Fehler beim sync to database",
|
||||
"metadataDiff.value.database": "Datenbank",
|
||||
"metadataDiff.value.file": "Datei",
|
||||
"metadataDiff.empty": "Klicke auf „Nach Unterschieden suchen“, um Datenbank-Metadaten mit Datei-Metadaten zu vergleichen."
|
||||
"metadataDiff.empty": "Klicke auf „Nach Unterschieden suchen“, um Datenbank-Metadaten mit Datei-Metadaten zu vergleichen.",
|
||||
"sidebar.archive": "Archiv",
|
||||
"sidebar.clearFilter": "Filter löschen",
|
||||
"sidebar.tags": "Tags",
|
||||
"sidebar.categories": "Kategorien",
|
||||
"sidebar.clearTags": "Tags löschen",
|
||||
"sidebar.clearCategories": "Kategorien löschen",
|
||||
"sidebar.noPostsYet": "Noch keine Beiträge",
|
||||
"sidebar.noPagesYet": "Noch keine Seiten",
|
||||
"sidebar.noMediaYet": "Noch keine Medien",
|
||||
"sidebar.search": "Suchen",
|
||||
"sidebar.searchPostsPlaceholder": "Beiträge durchsuchen...",
|
||||
"sidebar.searchPagesPlaceholder": "Seiten durchsuchen...",
|
||||
"sidebar.searchMediaPlaceholder": "Medien durchsuchen...",
|
||||
"sidebar.toggleFilters": "Filter umschalten",
|
||||
"sidebar.newPost": "Neuer Beitrag",
|
||||
"sidebar.importMedia": "Medien importieren",
|
||||
"sidebar.results": "{count} Ergebnisse",
|
||||
"sidebar.resultsFor": "{count} Ergebnisse für \"{query}\"",
|
||||
"sidebar.clearFilters": "Filter löschen",
|
||||
"sidebar.drafts": "Entwürfe",
|
||||
"sidebar.published": "Veröffentlicht",
|
||||
"sidebar.archived": "Archiviert",
|
||||
"sidebar.untitled": "Ohne Titel",
|
||||
"sidebar.noMatchingPosts": "Keine passenden Beiträge",
|
||||
"sidebar.createFirstPost": "Ersten Beitrag erstellen",
|
||||
"sidebar.loadMore": "Mehr laden ({loaded} von {total})",
|
||||
"sidebar.loading": "Lädt...",
|
||||
"sidebar.noMediaFiles": "Keine Mediendateien",
|
||||
"sidebar.settingsHeader": "Einstellungen",
|
||||
"sidebar.tagsHeader": "Tags",
|
||||
"sidebar.nav.project": "Projekt",
|
||||
"sidebar.nav.editor": "Texteditor",
|
||||
"sidebar.nav.content": "Inhalt",
|
||||
"sidebar.nav.ai": "KI-Assistent",
|
||||
"sidebar.nav.publishing": "Veröffentlichung",
|
||||
"sidebar.nav.data": "Daten",
|
||||
"sidebar.nav.style": "Stil",
|
||||
"sidebar.tagCloud": "Tag-Wolke",
|
||||
"sidebar.createEdit": "Erstellen & Bearbeiten",
|
||||
"sidebar.mergeTags": "Tags zusammenführen",
|
||||
"settings.project.descriptionGeneral": "Allgemeine Einstellungen für das aktive Blog-Projekt.",
|
||||
"settings.project.nameLabel": "Projektname",
|
||||
"settings.project.nameDescription": "Der Anzeigename deines Blog-Projekts.",
|
||||
"settings.project.namePlaceholder": "Mein Blog",
|
||||
"settings.project.descriptionLabel": "Beschreibung",
|
||||
"settings.project.descriptionDescription": "Eine kurze Beschreibung deines Blogs. Diese kann in Vorlagen und Metadaten verwendet werden.",
|
||||
"settings.project.descriptionPlaceholder": "Ein Blog über...",
|
||||
"settings.project.dataPathLabel": "Projekt-Datenpfad",
|
||||
"settings.project.dataPathDescription": "Benutzerdefinierter Ordner für Beiträge, Medien und Metadaten. Leer lassen, um den Standardpfad zu verwenden: {path}",
|
||||
"settings.project.defaultLocation": "Standardpfad",
|
||||
"settings.project.publicUrlLabel": "Öffentliche URL",
|
||||
"settings.project.publicUrlDescription": "Die öffentliche Basis-URL deines veröffentlichten Blogs (für Sitemap-Erstellung).",
|
||||
"settings.project.publicUrlPlaceholder": "https://example.com",
|
||||
"settings.project.mainLanguageLabel": "Hauptsprache",
|
||||
"settings.project.mainLanguageDescription": "Die primäre Sprache für deine Blog-Inhalte. KI-generierte Titel, Alt-Texte und Bildunterschriften nutzen diese Sprache.",
|
||||
"settings.project.defaultAuthorLabel": "Standardautor",
|
||||
"settings.project.defaultAuthorDescription": "Der Standard-Autorname für neue Beiträge und Medien. Kann pro Element überschrieben werden.",
|
||||
"settings.project.defaultAuthorPlaceholder": "Autorenname",
|
||||
"settings.project.maxPostsPerPageLabel": "Maximale Beiträge pro Seite",
|
||||
"settings.project.maxPostsPerPageDescription": "Maximale Anzahl von Beiträgen pro Vorschau-Routenseite.",
|
||||
"settings.project.saveButton": "Projekteinstellungen speichern",
|
||||
"editor.loadingPost": "Beitrag wird geladen...",
|
||||
"editor.unsavedChanges": "Ungespeicherte Änderungen (wird beim Wechsel automatisch gespeichert)",
|
||||
"editor.saving": "Speichern...",
|
||||
"editor.publish": "Veröffentlichen",
|
||||
"editor.publishTitle": "Speichern und öffentlich machen",
|
||||
"editor.discardChanges": "Änderungen verwerfen",
|
||||
"editor.discardDraft": "Entwurf verwerfen",
|
||||
"editor.discardChangesTitle": "Auf letzte veröffentlichte Version zurücksetzen",
|
||||
"editor.discardDraftTitle": "Diesen Entwurf dauerhaft löschen",
|
||||
"editor.delete": "Löschen",
|
||||
"editor.deleteTitle": "Diesen Beitrag dauerhaft löschen",
|
||||
"editor.field.title": "Titel",
|
||||
"editor.field.tags": "Tags",
|
||||
"editor.field.author": "Autor",
|
||||
"editor.field.slug": "Slug",
|
||||
"editor.field.categories": "Kategorien",
|
||||
"editor.field.content": "Inhalt",
|
||||
"editor.placeholder.tags": "Tags hinzufügen...",
|
||||
"editor.placeholder.author": "Autorenname",
|
||||
"editor.placeholder.categories": "Kategorien hinzufügen...",
|
||||
"editor.placeholder.startWriting": "Mit dem Schreiben beginnen...",
|
||||
"editor.mode.visual": "Visuell",
|
||||
"editor.mode.visualTitle": "Visueller Editor",
|
||||
"editor.mode.markdownTitle": "Markdown-Quelle",
|
||||
"editor.mode.previewTitle": "Schreibgeschützte Vorschau",
|
||||
"editor.galleryTitle": "{count} Bild(er) anzeigen",
|
||||
"editor.insertPostLinkTitle": "Beitrag verlinken (Strg+K)",
|
||||
"editor.insertMediaTitle": "Bild aus Medienbibliothek einfügen",
|
||||
"editor.previewFrameTitle": "Beitragsvorschau",
|
||||
"editor.previewLoading": "Vorschau wird geladen...",
|
||||
"editor.footer.created": "Erstellt",
|
||||
"editor.footer.updated": "Aktualisiert",
|
||||
"editor.footer.published": "Veröffentlicht"
|
||||
}
|
||||
|
||||
@@ -316,5 +316,99 @@
|
||||
"metadataDiff.sync.fileToDb.error": "Failed to sync to database",
|
||||
"metadataDiff.value.database": "Database",
|
||||
"metadataDiff.value.file": "File",
|
||||
"metadataDiff.empty": "Click \"Scan for Differences\" to compare database metadata with file metadata."
|
||||
"metadataDiff.empty": "Click \"Scan for Differences\" to compare database metadata with file metadata.",
|
||||
"sidebar.archive": "Archive",
|
||||
"sidebar.clearFilter": "Clear filter",
|
||||
"sidebar.tags": "Tags",
|
||||
"sidebar.categories": "Categories",
|
||||
"sidebar.clearTags": "Clear tags",
|
||||
"sidebar.clearCategories": "Clear categories",
|
||||
"sidebar.noPostsYet": "No posts yet",
|
||||
"sidebar.noPagesYet": "No pages yet",
|
||||
"sidebar.noMediaYet": "No media yet",
|
||||
"sidebar.search": "Search",
|
||||
"sidebar.searchPostsPlaceholder": "Search posts...",
|
||||
"sidebar.searchPagesPlaceholder": "Search pages...",
|
||||
"sidebar.searchMediaPlaceholder": "Search media...",
|
||||
"sidebar.toggleFilters": "Toggle Filters",
|
||||
"sidebar.newPost": "New Post",
|
||||
"sidebar.importMedia": "Import media",
|
||||
"sidebar.results": "{count} results",
|
||||
"sidebar.resultsFor": "{count} results for \"{query}\"",
|
||||
"sidebar.clearFilters": "Clear filters",
|
||||
"sidebar.drafts": "Drafts",
|
||||
"sidebar.published": "Published",
|
||||
"sidebar.archived": "Archived",
|
||||
"sidebar.untitled": "Untitled",
|
||||
"sidebar.noMatchingPosts": "No matching posts",
|
||||
"sidebar.createFirstPost": "Create your first post",
|
||||
"sidebar.loadMore": "Load more ({loaded} of {total})",
|
||||
"sidebar.loading": "Loading...",
|
||||
"sidebar.noMediaFiles": "No media files",
|
||||
"sidebar.settingsHeader": "Settings",
|
||||
"sidebar.tagsHeader": "Tags",
|
||||
"sidebar.nav.project": "Project",
|
||||
"sidebar.nav.editor": "Editor",
|
||||
"sidebar.nav.content": "Content",
|
||||
"sidebar.nav.ai": "AI Assistant",
|
||||
"sidebar.nav.publishing": "Publishing",
|
||||
"sidebar.nav.data": "Data",
|
||||
"sidebar.nav.style": "Style",
|
||||
"sidebar.tagCloud": "Tag Cloud",
|
||||
"sidebar.createEdit": "Create & Edit",
|
||||
"sidebar.mergeTags": "Merge Tags",
|
||||
"settings.project.descriptionGeneral": "General settings for the active blog project.",
|
||||
"settings.project.nameLabel": "Project Name",
|
||||
"settings.project.nameDescription": "The display name of your blog project.",
|
||||
"settings.project.namePlaceholder": "My Blog",
|
||||
"settings.project.descriptionLabel": "Description",
|
||||
"settings.project.descriptionDescription": "A short description of your blog. This can be used in templates and metadata.",
|
||||
"settings.project.descriptionPlaceholder": "A blog about...",
|
||||
"settings.project.dataPathLabel": "Project Data Path",
|
||||
"settings.project.dataPathDescription": "Custom folder for storing posts, media, and metadata. Leave empty to use the default location: {path}",
|
||||
"settings.project.defaultLocation": "Default location",
|
||||
"settings.project.publicUrlLabel": "Public URL",
|
||||
"settings.project.publicUrlDescription": "The public base URL of your published blog (used for sitemap generation).",
|
||||
"settings.project.publicUrlPlaceholder": "https://example.com",
|
||||
"settings.project.mainLanguageLabel": "Main Language",
|
||||
"settings.project.mainLanguageDescription": "The primary language for your blog content. AI-generated titles, alt text, and captions will use this language.",
|
||||
"settings.project.defaultAuthorLabel": "Default Author",
|
||||
"settings.project.defaultAuthorDescription": "The default author name for new posts and media. Can be overridden per item.",
|
||||
"settings.project.defaultAuthorPlaceholder": "Author Name",
|
||||
"settings.project.maxPostsPerPageLabel": "Max Posts Per Page",
|
||||
"settings.project.maxPostsPerPageDescription": "Maximum number of posts shown per preview route page.",
|
||||
"settings.project.saveButton": "Save Project Settings",
|
||||
"editor.loadingPost": "Loading post...",
|
||||
"editor.unsavedChanges": "Unsaved changes (auto-saves on switch)",
|
||||
"editor.saving": "Saving...",
|
||||
"editor.publish": "Publish",
|
||||
"editor.publishTitle": "Save and make this post public",
|
||||
"editor.discardChanges": "Discard Changes",
|
||||
"editor.discardDraft": "Discard Draft",
|
||||
"editor.discardChangesTitle": "Revert to last published version",
|
||||
"editor.discardDraftTitle": "Delete this draft permanently",
|
||||
"editor.delete": "Delete",
|
||||
"editor.deleteTitle": "Delete this post permanently",
|
||||
"editor.field.title": "Title",
|
||||
"editor.field.tags": "Tags",
|
||||
"editor.field.author": "Author",
|
||||
"editor.field.slug": "Slug",
|
||||
"editor.field.categories": "Categories",
|
||||
"editor.field.content": "Content",
|
||||
"editor.placeholder.tags": "Add tags...",
|
||||
"editor.placeholder.author": "Author name",
|
||||
"editor.placeholder.categories": "Add categories...",
|
||||
"editor.placeholder.startWriting": "Start writing...",
|
||||
"editor.mode.visual": "Visual",
|
||||
"editor.mode.visualTitle": "Visual editor",
|
||||
"editor.mode.markdownTitle": "Markdown source",
|
||||
"editor.mode.previewTitle": "Read-only preview",
|
||||
"editor.galleryTitle": "View {count} image(s)",
|
||||
"editor.insertPostLinkTitle": "Link to post (Ctrl+K)",
|
||||
"editor.insertMediaTitle": "Insert image from media library",
|
||||
"editor.previewFrameTitle": "Post preview",
|
||||
"editor.previewLoading": "Loading preview...",
|
||||
"editor.footer.created": "Created",
|
||||
"editor.footer.updated": "Updated",
|
||||
"editor.footer.published": "Published"
|
||||
}
|
||||
|
||||
@@ -316,5 +316,99 @@
|
||||
"metadataDiff.sync.fileToDb.error": "No se pudo sync to database",
|
||||
"metadataDiff.value.database": "Base de datos",
|
||||
"metadataDiff.value.file": "Archivo",
|
||||
"metadataDiff.empty": "Haz clic en \"Buscar diferencias\" para comparar metadatos de base de datos con metadatos de archivos."
|
||||
"metadataDiff.empty": "Haz clic en \"Buscar diferencias\" para comparar metadatos de base de datos con metadatos de archivos.",
|
||||
"sidebar.archive": "Archive",
|
||||
"sidebar.clearFilter": "Clear filter",
|
||||
"sidebar.tags": "Tags",
|
||||
"sidebar.categories": "Categories",
|
||||
"sidebar.clearTags": "Clear tags",
|
||||
"sidebar.clearCategories": "Clear categories",
|
||||
"sidebar.noPostsYet": "No posts yet",
|
||||
"sidebar.noPagesYet": "No pages yet",
|
||||
"sidebar.noMediaYet": "No media yet",
|
||||
"sidebar.search": "Search",
|
||||
"sidebar.searchPostsPlaceholder": "Search posts...",
|
||||
"sidebar.searchPagesPlaceholder": "Search pages...",
|
||||
"sidebar.searchMediaPlaceholder": "Search media...",
|
||||
"sidebar.toggleFilters": "Toggle Filters",
|
||||
"sidebar.newPost": "New Post",
|
||||
"sidebar.importMedia": "Import media",
|
||||
"sidebar.results": "{count} results",
|
||||
"sidebar.resultsFor": "{count} results for \"{query}\"",
|
||||
"sidebar.clearFilters": "Clear filters",
|
||||
"sidebar.drafts": "Drafts",
|
||||
"sidebar.published": "Published",
|
||||
"sidebar.archived": "Archived",
|
||||
"sidebar.untitled": "Untitled",
|
||||
"sidebar.noMatchingPosts": "No matching posts",
|
||||
"sidebar.createFirstPost": "Create your first post",
|
||||
"sidebar.loadMore": "Load more ({loaded} of {total})",
|
||||
"sidebar.loading": "Loading...",
|
||||
"sidebar.noMediaFiles": "No media files",
|
||||
"sidebar.settingsHeader": "Settings",
|
||||
"sidebar.tagsHeader": "Tags",
|
||||
"sidebar.nav.project": "Project",
|
||||
"sidebar.nav.editor": "Editor",
|
||||
"sidebar.nav.content": "Content",
|
||||
"sidebar.nav.ai": "AI Assistant",
|
||||
"sidebar.nav.publishing": "Publishing",
|
||||
"sidebar.nav.data": "Data",
|
||||
"sidebar.nav.style": "Style",
|
||||
"sidebar.tagCloud": "Tag Cloud",
|
||||
"sidebar.createEdit": "Create & Edit",
|
||||
"sidebar.mergeTags": "Merge Tags",
|
||||
"settings.project.descriptionGeneral": "General settings for the active blog project.",
|
||||
"settings.project.nameLabel": "Project Name",
|
||||
"settings.project.nameDescription": "The display name of your blog project.",
|
||||
"settings.project.namePlaceholder": "My Blog",
|
||||
"settings.project.descriptionLabel": "Description",
|
||||
"settings.project.descriptionDescription": "A short description of your blog. This can be used in templates and metadata.",
|
||||
"settings.project.descriptionPlaceholder": "A blog about...",
|
||||
"settings.project.dataPathLabel": "Project Data Path",
|
||||
"settings.project.dataPathDescription": "Custom folder for storing posts, media, and metadata. Leave empty to use the default location: {path}",
|
||||
"settings.project.defaultLocation": "Default location",
|
||||
"settings.project.publicUrlLabel": "Public URL",
|
||||
"settings.project.publicUrlDescription": "The public base URL of your published blog (used for sitemap generation).",
|
||||
"settings.project.publicUrlPlaceholder": "https://example.com",
|
||||
"settings.project.mainLanguageLabel": "Main Language",
|
||||
"settings.project.mainLanguageDescription": "The primary language for your blog content. AI-generated titles, alt text, and captions will use this language.",
|
||||
"settings.project.defaultAuthorLabel": "Default Author",
|
||||
"settings.project.defaultAuthorDescription": "The default author name for new posts and media. Can be overridden per item.",
|
||||
"settings.project.defaultAuthorPlaceholder": "Author Name",
|
||||
"settings.project.maxPostsPerPageLabel": "Max Posts Per Page",
|
||||
"settings.project.maxPostsPerPageDescription": "Maximum number of posts shown per preview route page.",
|
||||
"settings.project.saveButton": "Save Project Settings",
|
||||
"editor.loadingPost": "Loading post...",
|
||||
"editor.unsavedChanges": "Unsaved changes (auto-saves on switch)",
|
||||
"editor.saving": "Saving...",
|
||||
"editor.publish": "Publish",
|
||||
"editor.publishTitle": "Save and make this post public",
|
||||
"editor.discardChanges": "Discard Changes",
|
||||
"editor.discardDraft": "Discard Draft",
|
||||
"editor.discardChangesTitle": "Revert to last published version",
|
||||
"editor.discardDraftTitle": "Delete this draft permanently",
|
||||
"editor.delete": "Delete",
|
||||
"editor.deleteTitle": "Delete this post permanently",
|
||||
"editor.field.title": "Title",
|
||||
"editor.field.tags": "Tags",
|
||||
"editor.field.author": "Author",
|
||||
"editor.field.slug": "Slug",
|
||||
"editor.field.categories": "Categories",
|
||||
"editor.field.content": "Content",
|
||||
"editor.placeholder.tags": "Add tags...",
|
||||
"editor.placeholder.author": "Author name",
|
||||
"editor.placeholder.categories": "Add categories...",
|
||||
"editor.placeholder.startWriting": "Start writing...",
|
||||
"editor.mode.visual": "Visual",
|
||||
"editor.mode.visualTitle": "Visual editor",
|
||||
"editor.mode.markdownTitle": "Markdown source",
|
||||
"editor.mode.previewTitle": "Read-only preview",
|
||||
"editor.galleryTitle": "View {count} image(s)",
|
||||
"editor.insertPostLinkTitle": "Link to post (Ctrl+K)",
|
||||
"editor.insertMediaTitle": "Insert image from media library",
|
||||
"editor.previewFrameTitle": "Post preview",
|
||||
"editor.previewLoading": "Loading preview...",
|
||||
"editor.footer.created": "Created",
|
||||
"editor.footer.updated": "Updated",
|
||||
"editor.footer.published": "Published"
|
||||
}
|
||||
|
||||
@@ -316,5 +316,99 @@
|
||||
"metadataDiff.sync.fileToDb.error": "Échec de sync to database",
|
||||
"metadataDiff.value.database": "Base de données",
|
||||
"metadataDiff.value.file": "Fichier",
|
||||
"metadataDiff.empty": "Cliquez sur « Rechercher les différences » pour comparer les métadonnées de la base et celles des fichiers."
|
||||
"metadataDiff.empty": "Cliquez sur « Rechercher les différences » pour comparer les métadonnées de la base et celles des fichiers.",
|
||||
"sidebar.archive": "Archive",
|
||||
"sidebar.clearFilter": "Clear filter",
|
||||
"sidebar.tags": "Tags",
|
||||
"sidebar.categories": "Categories",
|
||||
"sidebar.clearTags": "Clear tags",
|
||||
"sidebar.clearCategories": "Clear categories",
|
||||
"sidebar.noPostsYet": "No posts yet",
|
||||
"sidebar.noPagesYet": "No pages yet",
|
||||
"sidebar.noMediaYet": "No media yet",
|
||||
"sidebar.search": "Search",
|
||||
"sidebar.searchPostsPlaceholder": "Search posts...",
|
||||
"sidebar.searchPagesPlaceholder": "Search pages...",
|
||||
"sidebar.searchMediaPlaceholder": "Search media...",
|
||||
"sidebar.toggleFilters": "Toggle Filters",
|
||||
"sidebar.newPost": "New Post",
|
||||
"sidebar.importMedia": "Import media",
|
||||
"sidebar.results": "{count} results",
|
||||
"sidebar.resultsFor": "{count} results for \"{query}\"",
|
||||
"sidebar.clearFilters": "Clear filters",
|
||||
"sidebar.drafts": "Drafts",
|
||||
"sidebar.published": "Published",
|
||||
"sidebar.archived": "Archived",
|
||||
"sidebar.untitled": "Untitled",
|
||||
"sidebar.noMatchingPosts": "No matching posts",
|
||||
"sidebar.createFirstPost": "Create your first post",
|
||||
"sidebar.loadMore": "Load more ({loaded} of {total})",
|
||||
"sidebar.loading": "Loading...",
|
||||
"sidebar.noMediaFiles": "No media files",
|
||||
"sidebar.settingsHeader": "Settings",
|
||||
"sidebar.tagsHeader": "Tags",
|
||||
"sidebar.nav.project": "Project",
|
||||
"sidebar.nav.editor": "Editor",
|
||||
"sidebar.nav.content": "Content",
|
||||
"sidebar.nav.ai": "AI Assistant",
|
||||
"sidebar.nav.publishing": "Publishing",
|
||||
"sidebar.nav.data": "Data",
|
||||
"sidebar.nav.style": "Style",
|
||||
"sidebar.tagCloud": "Tag Cloud",
|
||||
"sidebar.createEdit": "Create & Edit",
|
||||
"sidebar.mergeTags": "Merge Tags",
|
||||
"settings.project.descriptionGeneral": "General settings for the active blog project.",
|
||||
"settings.project.nameLabel": "Project Name",
|
||||
"settings.project.nameDescription": "The display name of your blog project.",
|
||||
"settings.project.namePlaceholder": "My Blog",
|
||||
"settings.project.descriptionLabel": "Description",
|
||||
"settings.project.descriptionDescription": "A short description of your blog. This can be used in templates and metadata.",
|
||||
"settings.project.descriptionPlaceholder": "A blog about...",
|
||||
"settings.project.dataPathLabel": "Project Data Path",
|
||||
"settings.project.dataPathDescription": "Custom folder for storing posts, media, and metadata. Leave empty to use the default location: {path}",
|
||||
"settings.project.defaultLocation": "Default location",
|
||||
"settings.project.publicUrlLabel": "Public URL",
|
||||
"settings.project.publicUrlDescription": "The public base URL of your published blog (used for sitemap generation).",
|
||||
"settings.project.publicUrlPlaceholder": "https://example.com",
|
||||
"settings.project.mainLanguageLabel": "Main Language",
|
||||
"settings.project.mainLanguageDescription": "The primary language for your blog content. AI-generated titles, alt text, and captions will use this language.",
|
||||
"settings.project.defaultAuthorLabel": "Default Author",
|
||||
"settings.project.defaultAuthorDescription": "The default author name for new posts and media. Can be overridden per item.",
|
||||
"settings.project.defaultAuthorPlaceholder": "Author Name",
|
||||
"settings.project.maxPostsPerPageLabel": "Max Posts Per Page",
|
||||
"settings.project.maxPostsPerPageDescription": "Maximum number of posts shown per preview route page.",
|
||||
"settings.project.saveButton": "Save Project Settings",
|
||||
"editor.loadingPost": "Loading post...",
|
||||
"editor.unsavedChanges": "Unsaved changes (auto-saves on switch)",
|
||||
"editor.saving": "Saving...",
|
||||
"editor.publish": "Publish",
|
||||
"editor.publishTitle": "Save and make this post public",
|
||||
"editor.discardChanges": "Discard Changes",
|
||||
"editor.discardDraft": "Discard Draft",
|
||||
"editor.discardChangesTitle": "Revert to last published version",
|
||||
"editor.discardDraftTitle": "Delete this draft permanently",
|
||||
"editor.delete": "Delete",
|
||||
"editor.deleteTitle": "Delete this post permanently",
|
||||
"editor.field.title": "Title",
|
||||
"editor.field.tags": "Tags",
|
||||
"editor.field.author": "Author",
|
||||
"editor.field.slug": "Slug",
|
||||
"editor.field.categories": "Categories",
|
||||
"editor.field.content": "Content",
|
||||
"editor.placeholder.tags": "Add tags...",
|
||||
"editor.placeholder.author": "Author name",
|
||||
"editor.placeholder.categories": "Add categories...",
|
||||
"editor.placeholder.startWriting": "Start writing...",
|
||||
"editor.mode.visual": "Visual",
|
||||
"editor.mode.visualTitle": "Visual editor",
|
||||
"editor.mode.markdownTitle": "Markdown source",
|
||||
"editor.mode.previewTitle": "Read-only preview",
|
||||
"editor.galleryTitle": "View {count} image(s)",
|
||||
"editor.insertPostLinkTitle": "Link to post (Ctrl+K)",
|
||||
"editor.insertMediaTitle": "Insert image from media library",
|
||||
"editor.previewFrameTitle": "Post preview",
|
||||
"editor.previewLoading": "Loading preview...",
|
||||
"editor.footer.created": "Created",
|
||||
"editor.footer.updated": "Updated",
|
||||
"editor.footer.published": "Published"
|
||||
}
|
||||
|
||||
@@ -316,5 +316,99 @@
|
||||
"metadataDiff.sync.fileToDb.error": "Impossibile sync to database",
|
||||
"metadataDiff.value.database": "Database locale",
|
||||
"metadataDiff.value.file": "File sorgente",
|
||||
"metadataDiff.empty": "Fai clic su \"Scansiona differenze\" per confrontare i metadati del database con quelli dei file."
|
||||
"metadataDiff.empty": "Fai clic su \"Scansiona differenze\" per confrontare i metadati del database con quelli dei file.",
|
||||
"sidebar.archive": "Archive",
|
||||
"sidebar.clearFilter": "Clear filter",
|
||||
"sidebar.tags": "Tags",
|
||||
"sidebar.categories": "Categories",
|
||||
"sidebar.clearTags": "Clear tags",
|
||||
"sidebar.clearCategories": "Clear categories",
|
||||
"sidebar.noPostsYet": "No posts yet",
|
||||
"sidebar.noPagesYet": "No pages yet",
|
||||
"sidebar.noMediaYet": "No media yet",
|
||||
"sidebar.search": "Search",
|
||||
"sidebar.searchPostsPlaceholder": "Search posts...",
|
||||
"sidebar.searchPagesPlaceholder": "Search pages...",
|
||||
"sidebar.searchMediaPlaceholder": "Search media...",
|
||||
"sidebar.toggleFilters": "Toggle Filters",
|
||||
"sidebar.newPost": "New Post",
|
||||
"sidebar.importMedia": "Import media",
|
||||
"sidebar.results": "{count} results",
|
||||
"sidebar.resultsFor": "{count} results for \"{query}\"",
|
||||
"sidebar.clearFilters": "Clear filters",
|
||||
"sidebar.drafts": "Drafts",
|
||||
"sidebar.published": "Published",
|
||||
"sidebar.archived": "Archived",
|
||||
"sidebar.untitled": "Untitled",
|
||||
"sidebar.noMatchingPosts": "No matching posts",
|
||||
"sidebar.createFirstPost": "Create your first post",
|
||||
"sidebar.loadMore": "Load more ({loaded} of {total})",
|
||||
"sidebar.loading": "Loading...",
|
||||
"sidebar.noMediaFiles": "No media files",
|
||||
"sidebar.settingsHeader": "Settings",
|
||||
"sidebar.tagsHeader": "Tags",
|
||||
"sidebar.nav.project": "Project",
|
||||
"sidebar.nav.editor": "Editor",
|
||||
"sidebar.nav.content": "Content",
|
||||
"sidebar.nav.ai": "AI Assistant",
|
||||
"sidebar.nav.publishing": "Publishing",
|
||||
"sidebar.nav.data": "Data",
|
||||
"sidebar.nav.style": "Style",
|
||||
"sidebar.tagCloud": "Tag Cloud",
|
||||
"sidebar.createEdit": "Create & Edit",
|
||||
"sidebar.mergeTags": "Merge Tags",
|
||||
"settings.project.descriptionGeneral": "General settings for the active blog project.",
|
||||
"settings.project.nameLabel": "Project Name",
|
||||
"settings.project.nameDescription": "The display name of your blog project.",
|
||||
"settings.project.namePlaceholder": "My Blog",
|
||||
"settings.project.descriptionLabel": "Description",
|
||||
"settings.project.descriptionDescription": "A short description of your blog. This can be used in templates and metadata.",
|
||||
"settings.project.descriptionPlaceholder": "A blog about...",
|
||||
"settings.project.dataPathLabel": "Project Data Path",
|
||||
"settings.project.dataPathDescription": "Custom folder for storing posts, media, and metadata. Leave empty to use the default location: {path}",
|
||||
"settings.project.defaultLocation": "Default location",
|
||||
"settings.project.publicUrlLabel": "Public URL",
|
||||
"settings.project.publicUrlDescription": "The public base URL of your published blog (used for sitemap generation).",
|
||||
"settings.project.publicUrlPlaceholder": "https://example.com",
|
||||
"settings.project.mainLanguageLabel": "Main Language",
|
||||
"settings.project.mainLanguageDescription": "The primary language for your blog content. AI-generated titles, alt text, and captions will use this language.",
|
||||
"settings.project.defaultAuthorLabel": "Default Author",
|
||||
"settings.project.defaultAuthorDescription": "The default author name for new posts and media. Can be overridden per item.",
|
||||
"settings.project.defaultAuthorPlaceholder": "Author Name",
|
||||
"settings.project.maxPostsPerPageLabel": "Max Posts Per Page",
|
||||
"settings.project.maxPostsPerPageDescription": "Maximum number of posts shown per preview route page.",
|
||||
"settings.project.saveButton": "Save Project Settings",
|
||||
"editor.loadingPost": "Loading post...",
|
||||
"editor.unsavedChanges": "Unsaved changes (auto-saves on switch)",
|
||||
"editor.saving": "Saving...",
|
||||
"editor.publish": "Publish",
|
||||
"editor.publishTitle": "Save and make this post public",
|
||||
"editor.discardChanges": "Discard Changes",
|
||||
"editor.discardDraft": "Discard Draft",
|
||||
"editor.discardChangesTitle": "Revert to last published version",
|
||||
"editor.discardDraftTitle": "Delete this draft permanently",
|
||||
"editor.delete": "Delete",
|
||||
"editor.deleteTitle": "Delete this post permanently",
|
||||
"editor.field.title": "Title",
|
||||
"editor.field.tags": "Tags",
|
||||
"editor.field.author": "Author",
|
||||
"editor.field.slug": "Slug",
|
||||
"editor.field.categories": "Categories",
|
||||
"editor.field.content": "Content",
|
||||
"editor.placeholder.tags": "Add tags...",
|
||||
"editor.placeholder.author": "Author name",
|
||||
"editor.placeholder.categories": "Add categories...",
|
||||
"editor.placeholder.startWriting": "Start writing...",
|
||||
"editor.mode.visual": "Visual",
|
||||
"editor.mode.visualTitle": "Visual editor",
|
||||
"editor.mode.markdownTitle": "Markdown source",
|
||||
"editor.mode.previewTitle": "Read-only preview",
|
||||
"editor.galleryTitle": "View {count} image(s)",
|
||||
"editor.insertPostLinkTitle": "Link to post (Ctrl+K)",
|
||||
"editor.insertMediaTitle": "Insert image from media library",
|
||||
"editor.previewFrameTitle": "Post preview",
|
||||
"editor.previewLoading": "Loading preview...",
|
||||
"editor.footer.created": "Created",
|
||||
"editor.footer.updated": "Updated",
|
||||
"editor.footer.published": "Published"
|
||||
}
|
||||
|
||||
62
tests/renderer/components/SettingsView.i18n.test.tsx
Normal file
62
tests/renderer/components/SettingsView.i18n.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SettingsView } from '../../../src/renderer/components/SettingsView/SettingsView';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
import { I18nProvider, UI_LANGUAGE_STORAGE_KEY } from '../../../src/renderer/i18n';
|
||||
|
||||
describe('SettingsView i18n', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'de');
|
||||
(window.electronAPI.app as { getSystemLanguage?: () => Promise<string> }).getSystemLanguage = async () => 'de-DE';
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: {
|
||||
id: 'project-1',
|
||||
name: 'Testprojekt',
|
||||
slug: 'testprojekt',
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
gitDiffPreferences: {
|
||||
wordWrap: true,
|
||||
viewStyle: 'inline',
|
||||
hideUnchangedRegions: false,
|
||||
},
|
||||
} as never);
|
||||
|
||||
(window as Window & { electronAPI: any }).electronAPI = {
|
||||
...(window as Window & { electronAPI: any }).electronAPI,
|
||||
app: {
|
||||
...(window as Window & { electronAPI: any }).electronAPI?.app,
|
||||
getDefaultProjectPath: vi.fn().mockResolvedValue('/repo/path'),
|
||||
},
|
||||
meta: {
|
||||
...(window as Window & { electronAPI: any }).electronAPI?.meta,
|
||||
getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']),
|
||||
getProjectMetadata: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
chat: {
|
||||
...(window as Window & { electronAPI: any }).electronAPI?.chat,
|
||||
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
|
||||
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('renders project section labels in selected UI language', async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<SettingsView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Projekt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Projektname')).toBeInTheDocument();
|
||||
expect(screen.getByText('Projekteinstellungen speichern')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
34
tests/renderer/components/Sidebar.i18n.test.tsx
Normal file
34
tests/renderer/components/Sidebar.i18n.test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Sidebar } from '../../../src/renderer/components/Sidebar/Sidebar';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
import { I18nProvider, UI_LANGUAGE_STORAGE_KEY } from '../../../src/renderer/i18n';
|
||||
|
||||
describe('Sidebar i18n', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'de');
|
||||
(window.electronAPI.app as { getSystemLanguage?: () => Promise<string> }).getSystemLanguage = async () => 'de-DE';
|
||||
|
||||
useAppStore.setState({
|
||||
activeView: 'settings',
|
||||
sidebarVisible: true,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('renders settings navigation labels in selected UI language', async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Sidebar />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('EINSTELLUNGEN')).toBeInTheDocument();
|
||||
expect(screen.getByText('Projekt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Texteditor')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { StatusBar } from '../../../src/renderer/components/StatusBar/StatusBar';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
import { I18nProvider } from '../../../src/renderer/i18n';
|
||||
|
||||
vi.mock('../../../src/renderer/components/ProjectSelector', () => ({
|
||||
ProjectSelector: () => <div data-testid="project-selector">Project</div>,
|
||||
@@ -11,6 +12,8 @@ vi.mock('../../../src/renderer/components/ProjectSelector', () => ({
|
||||
describe('StatusBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
(window.electronAPI.app as { getSystemLanguage?: () => Promise<string> }).getSystemLanguage = async () => 'de-DE';
|
||||
useAppStore.setState({
|
||||
media: [],
|
||||
tasks: [],
|
||||
@@ -20,9 +23,31 @@ describe('StatusBar', () => {
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('shows the currently applied theme', () => {
|
||||
render(<StatusBar />);
|
||||
it('shows the currently applied theme', async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<StatusBar />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
await screen.findByTestId('statusbar-language-select');
|
||||
|
||||
expect(screen.getByText('Theme: slate')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows ui language badge and allows switching language', async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<StatusBar />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
const languageSelect = await screen.findByTestId('statusbar-language-select');
|
||||
expect(languageSelect).toHaveValue('de');
|
||||
|
||||
fireEvent.change(languageSelect, { target: { value: 'fr' } });
|
||||
expect(languageSelect).toHaveValue('fr');
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('bds-ui-language', 'fr');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import {
|
||||
I18nProvider,
|
||||
UI_LANGUAGE_STORAGE_KEY,
|
||||
useI18n,
|
||||
translateUi,
|
||||
resolveSupportedUiLanguage,
|
||||
resolveUiLanguageFromSystemLocale,
|
||||
} from '../../src/renderer/i18n';
|
||||
|
||||
const LanguageProbe: React.FC = () => {
|
||||
const { language, t } = useI18n();
|
||||
return React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement('span', { 'data-testid': 'language' }, language),
|
||||
React.createElement('span', { 'data-testid': 'save-label' }, t('common.save')),
|
||||
);
|
||||
};
|
||||
|
||||
describe('renderer i18n', () => {
|
||||
it('resolves supported ui language from OS locale', () => {
|
||||
expect(resolveUiLanguageFromSystemLocale('de-DE')).toBe('de');
|
||||
@@ -24,4 +39,24 @@ describe('renderer i18n', () => {
|
||||
expect(translateUi('de', 'settings.language.english')).toBe('Englisch');
|
||||
expect(translateUi('it', 'missing.key')).toBe('missing.key');
|
||||
});
|
||||
|
||||
it('uses system locale for ui language when no persisted choice exists', async () => {
|
||||
localStorage.removeItem(UI_LANGUAGE_STORAGE_KEY);
|
||||
(window.electronAPI.app as { getSystemLanguage?: () => Promise<string> }).getSystemLanguage = async () => 'de-DE';
|
||||
|
||||
render(React.createElement(I18nProvider, null, React.createElement(LanguageProbe)));
|
||||
|
||||
expect(await screen.findByTestId('language')).toHaveTextContent('de');
|
||||
expect(screen.getByTestId('save-label')).toHaveTextContent('Speichern');
|
||||
});
|
||||
|
||||
it('uses persisted ui language over system locale', async () => {
|
||||
localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'fr');
|
||||
(window.electronAPI.app as { getSystemLanguage?: () => Promise<string> }).getSystemLanguage = async () => 'de-DE';
|
||||
|
||||
render(React.createElement(I18nProvider, null, React.createElement(LanguageProbe)));
|
||||
|
||||
expect(await screen.findByTestId('language')).toHaveTextContent('fr');
|
||||
expect(screen.getByTestId('save-label')).toHaveTextContent('Enregistrer');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user