chore: more i18n going on

This commit is contained in:
2026-02-21 13:15:58 +01:00
parent dbef7ef98b
commit 0082291fa4
15 changed files with 1552 additions and 413 deletions

View File

@@ -358,8 +358,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
console.error('Failed to save post:', error);
const err = error as Error;
showErrorModal({
title: 'Save Failed',
message: err.message || 'Failed to save post',
title: tr('editor.error.saveTitle'),
message: err.message || tr('editor.error.saveMessage'),
stack: err.stack,
});
} finally {
@@ -374,14 +374,14 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
if (updated) {
updatePost(postId, updated as Partial<PostData>);
setPost(prev => prev ? { ...prev, ...updated as Partial<PostData> } : prev);
showToast.success('Post published');
showToast.success(tr('editor.toast.published'));
}
} catch (error) {
console.error('Failed to publish post:', error);
const err = error as Error;
showErrorModal({
title: 'Publish Failed',
message: err.message || 'Failed to publish post',
title: tr('editor.error.publishTitle'),
message: err.message || tr('editor.error.publishMessage'),
stack: err.stack,
});
}
@@ -391,8 +391,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
// If this post has a published version, revert to it
// If never published, delete the post entirely
const confirmMessage = hasPublishedVersion
? 'Discard all changes since last publish? This cannot be undone.'
: 'Delete this draft? This cannot be undone.';
? tr('editor.confirm.discardChanges')
: tr('editor.confirm.deleteDraft');
if (!confirm(confirmMessage)) {
return;
@@ -412,7 +412,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
setPost(reverted as PostData);
updatePost(postId, reverted as Partial<PostData>);
markClean(postId);
showToast.success('Reverted to last published version');
showToast.success(tr('editor.toast.reverted'));
}
} else {
// Never published - delete the post entirely
@@ -421,14 +421,14 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
pendingChangesRef.current = null;
useAppStore.getState().removePost(postId);
useAppStore.getState().closeTab(postId);
showToast.success('Draft deleted');
showToast.success(tr('editor.toast.draftDeleted'));
}
} catch (error) {
console.error('Failed to discard/delete:', error);
const err = error as Error;
showErrorModal({
title: hasPublishedVersion ? 'Discard Failed' : 'Delete Failed',
message: err.message || 'Operation failed',
title: hasPublishedVersion ? tr('editor.error.discardTitle') : tr('editor.error.deleteTitle'),
message: err.message || tr('editor.error.operationMessage'),
stack: err.stack,
});
}
@@ -469,7 +469,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
// Show confirmation modal
showConfirmDeleteModal({
itemType: 'post',
itemTitle: title || 'Untitled',
itemTitle: title || tr('editor.untitled'),
references,
onConfirm: async () => {
try {
@@ -479,13 +479,13 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
useAppStore.getState().removePost(postId);
useAppStore.getState().closeTab(postId);
useAppStore.getState().setSelectedPost(null);
showToast.success('Post deleted');
showToast.success(tr('editor.toast.postDeleted'));
} catch (error) {
console.error('Failed to delete post:', error);
const err = error as Error;
showErrorModal({
title: 'Delete Failed',
message: err.message || 'Failed to delete post',
title: tr('editor.error.deleteTitle'),
message: err.message || tr('editor.error.deletePostMessage'),
stack: err.stack,
});
}
@@ -495,8 +495,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
console.error('Failed to fetch post references:', error);
const err = error as Error;
showErrorModal({
title: 'Error',
message: err.message || 'Failed to fetch post references',
title: tr('errorModal.error'),
message: err.message || tr('editor.error.fetchPostReferencesMessage'),
stack: err.stack,
});
}
@@ -930,6 +930,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
};
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const { t: tr } = useI18n();
const { media, updateMedia, showErrorModal, showConfirmDeleteModal, openTab } = useAppStore();
const item = media.find(m => m.id === mediaId);
@@ -997,11 +998,11 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
caption: result.caption,
});
} else {
setAIError(result?.error || 'Failed to analyze image');
setAIError(result?.error || tr('editor.media.error.analyzeImage'));
}
} catch (error) {
console.error('Failed to analyze image:', error);
setAIError((error as Error).message || 'Failed to analyze image');
setAIError((error as Error).message || tr('editor.media.error.analyzeImage'));
} finally {
setIsAnalyzing(false);
}
@@ -1014,7 +1015,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
if (values.caption) setCaption(values.caption);
setShowAISuggestionsModal(false);
if (Object.keys(values).length > 0) {
showToast.success('AI suggestions applied');
showToast.success(tr('editor.media.toast.aiApplied'));
}
};
@@ -1038,7 +1039,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
for (const link of links) {
const post = await window.electronAPI?.posts.get(link.postId);
if (post) {
titles.set(link.postId, post.title || 'Untitled');
titles.set(link.postId, post.title || tr('editor.untitled'));
}
}
setPostTitles(titles);
@@ -1057,7 +1058,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
try {
const result = await window.electronAPI?.posts.getAll({ limit: 100, offset: 0 });
if (result?.items) {
setPickerPosts(result.items.map(p => ({ id: p.id, title: p.title || 'Untitled' })));
setPickerPosts(result.items.map(p => ({ id: p.id, title: p.title || tr('editor.untitled') })));
}
} catch (error) {
console.error('Failed to load posts for picker:', error);
@@ -1068,7 +1069,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
// Get post titles for display
const getPostTitle = (postId: string): string => {
return postTitles.get(postId) || 'Loading...';
return postTitles.get(postId) || tr('sidebar.loading');
};
// Handle linking to a new post
@@ -1079,10 +1080,10 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
setPostTitles(prev => new Map(prev).set(postId, postTitle));
setShowPostPicker(false);
setPostSearchQuery('');
showToast.success('Linked to post');
showToast.success(tr('editor.media.toast.linkedToPost'));
} catch (error) {
console.error('Failed to link to post:', error);
showToast.error('Failed to link to post');
showToast.error(tr('editor.media.toast.linkFailed'));
}
};
@@ -1091,10 +1092,10 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
try {
await window.electronAPI?.postMedia.unlink(postId, mediaId);
setLinkedPosts(linkedPosts.filter(l => l.postId !== postId));
showToast.success('Unlinked from post');
showToast.success(tr('editor.media.toast.unlinkedFromPost'));
} catch (error) {
console.error('Failed to unlink from post:', error);
showToast.error('Failed to unlink from post');
showToast.error(tr('editor.media.toast.unlinkFailed'));
}
};
@@ -1121,7 +1122,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
}, [item?.id]);
if (!item) {
return <div className="editor-empty">Media not found</div>;
return <div className="editor-empty">{tr('editor.media.notFound')}</div>;
}
const handleSave = async () => {
@@ -1135,14 +1136,14 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
});
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
showToast.success('Media updated');
showToast.success(tr('editor.media.toast.updated'));
}
} catch (error) {
console.error('Failed to update media:', error);
const err = error as Error;
showErrorModal({
title: 'Update Failed',
message: err.message || 'Failed to update media',
title: tr('editor.media.error.updateTitle'),
message: err.message || tr('editor.media.error.updateMessage'),
stack: err.stack,
});
}
@@ -1153,15 +1154,15 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const updated = await window.electronAPI?.media.replaceFileDialog(item.id);
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
showToast.success('File replaced (thumbnails regenerated)');
showToast.success(tr('editor.media.toast.fileReplaced'));
}
// null means user cancelled or file unchanged - no action needed
} catch (error) {
console.error('Failed to replace media file:', error);
const err = error as Error;
showErrorModal({
title: 'Replace Failed',
message: err.message || 'Failed to replace media file',
title: tr('editor.media.error.replaceTitle'),
message: err.message || tr('editor.media.error.replaceMessage'),
stack: err.stack,
});
}
@@ -1182,7 +1183,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
if (post) {
references.push({
id: post.id,
title: post.title || 'Untitled',
title: post.title || tr('editor.untitled'),
type: 'post',
});
}
@@ -1198,13 +1199,13 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
try {
await window.electronAPI?.media.delete(item.id);
useAppStore.getState().removeMedia(item.id);
showToast.success('Media deleted');
showToast.success(tr('editor.media.toast.deleted'));
} catch (error) {
console.error('Failed to delete media:', error);
const err = error as Error;
showErrorModal({
title: 'Delete Failed',
message: err.message || 'Failed to delete media',
title: tr('editor.error.deleteTitle'),
message: err.message || tr('editor.media.error.deleteMessage'),
stack: err.stack,
});
}
@@ -1214,8 +1215,8 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
console.error('Failed to fetch media references:', error);
const err = error as Error;
showErrorModal({
title: 'Error',
message: err.message || 'Failed to fetch media references',
title: tr('errorModal.error'),
message: err.message || tr('editor.media.error.fetchReferencesMessage'),
stack: err.stack,
});
}
@@ -1237,9 +1238,9 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
className="secondary quick-actions-btn"
onClick={() => setShowQuickActions(!showQuickActions)}
disabled={isAnalyzing}
title="Quick Actions"
title={tr('editor.media.quickActions.title')}
>
{isAnalyzing ? '⏳ Analyzing...' : '⚡ Quick Actions'}
{isAnalyzing ? tr('editor.media.quickActions.analyzing') : tr('editor.media.quickActions.button')}
</button>
{showQuickActions && (
<div className="quick-actions-menu">
@@ -1250,17 +1251,17 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
>
<span className="quick-action-icon">🤖</span>
<span className="quick-action-text">
<strong>AI: Generate Title, Alt & Caption</strong>
<small>Analyzes the image to suggest metadata</small>
<strong>{tr('editor.media.quickActions.aiTitle')}</strong>
<small>{tr('editor.media.quickActions.aiDescription')}</small>
</span>
</button>
</div>
)}
</div>
)}
<button onClick={handleReplaceFile} className="secondary">Replace File</button>
<button onClick={handleSave}>Save</button>
<button onClick={handleDelete} className="secondary danger">Delete</button>
<button onClick={handleReplaceFile} className="secondary">{tr('editor.media.replaceFile')}</button>
<button onClick={handleSave}>{tr('common.save')}</button>
<button onClick={handleDelete} className="secondary danger">{tr('editor.delete')}</button>
</div>
</div>
@@ -1291,81 +1292,81 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
<div className="media-details">
<div className="editor-field">
<label>File Name</label>
<label>{tr('editor.media.field.fileName')}</label>
<input type="text" value={item.originalName} disabled className="disabled" />
</div>
<div className="editor-field">
<label>Type</label>
<label>{tr('editor.media.field.type')}</label>
<input type="text" value={item.mimeType} disabled className="disabled" />
</div>
<div className="editor-field-row">
<div className="editor-field">
<label>Size</label>
<label>{tr('editor.media.field.size')}</label>
<input type="text" value={`${(item.size / 1024).toFixed(1)} KB`} disabled className="disabled" />
</div>
{item.width && item.height && (
<div className="editor-field">
<label>Dimensions</label>
<label>{tr('editor.media.field.dimensions')}</label>
<input type="text" value={`${item.width} × ${item.height}`} disabled className="disabled" />
</div>
)}
</div>
<div className="editor-field">
<label>Title</label>
<label>{tr('editor.media.field.title')}</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title for lists and search results"
placeholder={tr('editor.media.placeholder.title')}
/>
</div>
<div className="editor-field">
<label>Alt Text</label>
<label>{tr('editor.media.field.altText')}</label>
<input
type="text"
value={alt}
onChange={(e) => setAlt(e.target.value)}
placeholder="Describe the image for accessibility"
placeholder={tr('editor.media.placeholder.altText')}
/>
</div>
<div className="editor-field">
<label>Caption</label>
<label>{tr('editor.media.field.caption')}</label>
<textarea
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="Image caption"
placeholder={tr('editor.media.placeholder.caption')}
rows={3}
/>
</div>
<div className="editor-field">
<label>Tags (comma-separated)</label>
<label>{tr('editor.media.field.tags')}</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="tag1, tag2, tag3"
placeholder={tr('editor.media.placeholder.tags')}
/>
</div>
<div className="editor-field">
<label>Author</label>
<label>{tr('editor.media.field.author')}</label>
<input
type="text"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder="Author name"
placeholder={tr('editor.media.placeholder.author')}
/>
</div>
{/* Linked Posts Section */}
<div className="editor-field linked-posts-section">
<label>
Linked Posts
{tr('editor.media.linkedPosts')}
<button
className="add-link-btn"
onClick={() => setShowPostPicker(!showPostPicker)}
title="Link to a post"
title={tr('editor.media.linkToPostTitle')}
>
+ Link
{tr('editor.media.linkAction')}
</button>
</label>
@@ -1374,14 +1375,14 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
<div className="post-picker-search">
<input
type="text"
placeholder="Search posts..."
placeholder={tr('editor.media.searchPosts')}
value={postSearchQuery}
onChange={(e) => setPostSearchQuery(e.target.value)}
autoFocus
/>
</div>
{unlinkedPosts.length === 0 ? (
<div className="no-posts">{postSearchQuery ? 'No matching posts' : 'No posts available to link'}</div>
<div className="no-posts">{postSearchQuery ? tr('editor.media.noMatchingPosts') : tr('editor.media.noPostsToLink')}</div>
) : (
<div className="post-picker-list">
{unlinkedPosts.slice(0, 10).map(post => (
@@ -1395,7 +1396,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
))}
{unlinkedPosts.length > 10 && (
<div className="post-picker-more">
+{unlinkedPosts.length - 10} more posts
{tr('editor.media.morePosts', { count: unlinkedPosts.length - 10 })}
</div>
)}
</div>
@@ -1404,7 +1405,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
)}
{linkedPosts.length === 0 ? (
<div className="no-linked-posts">Not linked to any posts</div>
<div className="no-linked-posts">{tr('editor.media.notLinked')}</div>
) : (
<div className="linked-posts-list">
{linkedPosts.map(({ postId }) => (
@@ -1412,14 +1413,14 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
<span
className="linked-post-title"
onClick={() => handlePostClick(postId)}
title="Open post"
title={tr('editor.media.openPost')}
>
📄 {getPostTitle(postId)}
</span>
<button
className="unlink-btn"
onClick={() => handleUnlinkFromPost(postId)}
title="Unlink from post"
title={tr('editor.media.unlinkFromPost')}
>
×
</button>

View File

@@ -1,5 +1,6 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import type { ChatModel } from '../../types/electron';
import { useI18n } from '../../i18n';
import './ImportAnalysisView.css';
/** How to resolve a slug conflict during import */
@@ -155,7 +156,8 @@ const formatEta = (etaMs: number): string => {
};
export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definitionId }) => {
const [name, setName] = useState('Untitled Import');
const { t } = useI18n();
const [name, setName] = useState(t('importAnalysis.untitledImport'));
const [uploadsFolder, setUploadsFolder] = useState<string | null>(null);
const [wxrFilePath, setWxrFilePath] = useState<string | null>(null);
const [report, setReport] = useState<AnalysisReport | null>(null);
@@ -333,7 +335,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
}, [definitionId]);
const handleNameBlur = useCallback(async () => {
const trimmed = name.trim() || 'Untitled Import';
const trimmed = name.trim() || t('importAnalysis.untitledImport');
setName(trimmed);
await window.electronAPI?.importDefinitions.update(definitionId, { name: trimmed });
}, [definitionId, name]);
@@ -378,7 +380,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
setExecutionState({
isExecuting: true,
taskId: null,
phase: 'Starting...',
phase: t('importAnalysis.executionStarting'),
current: 0,
total: 0,
detail: '',
@@ -405,7 +407,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
setExecutionState(prev => ({
...prev,
isExecuting: false,
error: error instanceof Error ? error.message : 'Unknown error',
error: error instanceof Error ? error.message : t('importAnalysis.unknownError'),
}));
}
}, [report, uploadsFolder]);
@@ -478,7 +480,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
<div className="import-analysis">
<div className="import-loading">
<div className="import-spinner" />
Loading import definition...
{t('importAnalysis.loadingDefinition')}
</div>
</div>
);
@@ -494,30 +496,30 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
onChange={(e) => setName(e.target.value)}
onBlur={handleNameBlur}
onKeyDown={(e) => { if (e.key === 'Enter') nameInputRef.current?.blur(); }}
placeholder="Import name..."
placeholder={t('importAnalysis.namePlaceholder')}
/>
<p>Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.</p>
<p>{t('importAnalysis.headerDescription')}</p>
</div>
<div className="import-file-selectors">
<div className="import-file-row">
<label>Uploads Folder</label>
<label>{t('importAnalysis.uploadsFolder')}</label>
<div className={`import-file-path ${!uploadsFolder ? 'placeholder' : ''}`}>
{uploadsFolder || 'No folder selected'}
{uploadsFolder || t('importAnalysis.noFolderSelected')}
</div>
<button onClick={handleSelectUploadsFolder}>Browse...</button>
<button onClick={handleSelectUploadsFolder}>{t('settings.project.browse')}...</button>
</div>
<div className="import-file-row">
<label>WXR File</label>
<label>{t('importAnalysis.wxrFile')}</label>
<div className={`import-file-path ${!wxrFilePath ? 'placeholder' : ''}`}>
{wxrFilePath || report?.sourceFile || 'Select a file to analyze'}
{wxrFilePath || report?.sourceFile || t('importAnalysis.selectFileToAnalyze')}
</div>
<button
className="import-analyze-btn"
onClick={handleSelectAndAnalyze}
disabled={isLoading}
>
{isLoading ? 'Analyzing...' : 'Select & Analyze'}
{isLoading ? t('importAnalysis.analyzing') : t('importAnalysis.selectAndAnalyze')}
</button>
</div>
</div>
@@ -526,7 +528,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
<div className="import-loading">
<div className="import-spinner" />
<div className="import-progress">
<div className="import-progress-step">{progressStep || 'Analyzing WXR file...'}</div>
<div className="import-progress-step">{progressStep || t('importAnalysis.analyzingWxr')}</div>
{progressDetail && <div className="import-progress-detail">{progressDetail}</div>}
</div>
</div>
@@ -537,7 +539,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
<p>Select a WordPress export file to begin analysis.</p>
<p>{t('importAnalysis.emptyState')}</p>
</div>
)}
@@ -551,7 +553,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
{executionState.isExecuting && (
<div className="import-execution-progress">
<div className="import-execution-header">
<h3>Importing...</h3>
<h3>{t('importAnalysis.importing')}</h3>
{executionState.eta !== null && executionState.eta > 0 && (
<span className="import-eta">{formatEta(executionState.eta)}</span>
)}
@@ -576,7 +578,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
<span>Import completed successfully!</span>
<span>{t('importAnalysis.importComplete')}</span>
</div>
)}
@@ -586,7 +588,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>
<span>Import failed: {executionState.error}</span>
<span>{t('importAnalysis.importFailed', { error: executionState.error })}</span>
</div>
)}
@@ -598,18 +600,20 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
return (
<div className="import-execute-section">
<div className="import-execute-summary">
Ready to import:
{counts.tags > 0 && <span className="import-count-tag">{counts.tags} tags/categories</span>}
{counts.posts > 0 && <span className="import-count-tag">{counts.posts} posts</span>}
{counts.media > 0 && <span className="import-count-tag">{counts.media} media</span>}
{counts.pages > 0 && <span className="import-count-tag">{counts.pages} pages</span>}
{t('importAnalysis.readyToImport')}
{counts.tags > 0 && <span className="import-count-tag">{counts.tags} {t('importAnalysis.tagsCategories')}</span>}
{counts.posts > 0 && <span className="import-count-tag">{counts.posts} {t('importAnalysis.posts')}</span>}
{counts.media > 0 && <span className="import-count-tag">{counts.media} {t('importAnalysis.media')}</span>}
{counts.pages > 0 && <span className="import-count-tag">{counts.pages} {t('importAnalysis.pages')}</span>}
</div>
<button
className="import-execute-btn"
onClick={handleExecuteImport}
disabled={totalImportable === 0}
>
{totalImportable === 0 ? 'Nothing to Import' : `Import ${totalImportable} Items`}
{totalImportable === 0
? t('importAnalysis.nothingToImport')
: t('importAnalysis.importItems', { count: totalImportable })}
</button>
</div>
);
@@ -618,7 +622,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
{report.posts.conflicts > 0 && (
<ConflictsSection
title="Post Slug Conflicts"
title={t('importAnalysis.postSlugConflicts')}
items={report.posts.items.filter(i => i.status === 'conflict')}
expanded={expandedSections['post-conflicts'] ?? true}
onToggle={() => toggleSection('post-conflicts')}
@@ -628,7 +632,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
{report.pages.conflicts > 0 && (
<ConflictsSection
title="Page Slug Conflicts"
title={t('importAnalysis.pageSlugConflicts')}
items={report.pages.items.filter(i => i.status === 'conflict')}
expanded={expandedSections['page-conflicts'] ?? true}
onToggle={() => toggleSection('page-conflicts')}
@@ -641,7 +645,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
const postsOnly = report.posts.items.filter(i => i.wxrPost.postType === 'post');
return postsOnly.length > 0 && (
<PostDetailSection
title={`Posts (${postsOnly.length})`}
title={t('importAnalysis.postsWithCount', { count: postsOnly.length })}
items={postsOnly}
expanded={expandedSections['posts'] ?? false}
onToggle={() => toggleSection('posts')}
@@ -654,7 +658,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
const otherPosts = report.posts.items.filter(i => i.wxrPost.postType !== 'post');
return otherPosts.length > 0 && (
<PostDetailSection
title={`Other (${otherPosts.length})`}
title={t('importAnalysis.otherWithCount', { count: otherPosts.length })}
items={otherPosts}
expanded={expandedSections['other'] ?? false}
onToggle={() => toggleSection('other')}
@@ -665,7 +669,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
{report.pages.total > 0 && (
<PostDetailSection
title={`Pages (${report.pages.total})`}
title={t('importAnalysis.pagesWithCount', { count: report.pages.total })}
items={report.pages.items}
expanded={expandedSections['pages'] ?? false}
onToggle={() => toggleSection('pages')}
@@ -673,7 +677,7 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
)}
<MediaDetailSection
title={`Media (${report.media.total})`}
title={t('importAnalysis.mediaWithCount', { count: report.media.total })}
items={report.media.items}
expanded={expandedSections['media'] ?? false}
onToggle={() => toggleSection('media')}
@@ -703,28 +707,31 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
);
};
const SiteInfoCard: React.FC<{ site: AnalysisReport['site']; sourceFile: string }> = ({ site, sourceFile }) => (
<div className="import-site-info">
const SiteInfoCard: React.FC<{ site: AnalysisReport['site']; sourceFile: string }> = ({ site, sourceFile }) => {
const { t } = useI18n();
return <div className="import-site-info">
<div className="import-site-info-item">
<span className="info-label">Site</span>
<span className="info-value">{site.title || 'Untitled'}</span>
<span className="info-label">{t('importAnalysis.site')}</span>
<span className="info-value">{site.title || t('importAnalysis.untitled')}</span>
</div>
<div className="import-site-info-item">
<span className="info-label">URL</span>
<span className="info-value">{site.link || 'N/A'}</span>
<span className="info-label">{t('importAnalysis.url')}</span>
<span className="info-value">{site.link || t('importAnalysis.notAvailable')}</span>
</div>
<div className="import-site-info-item">
<span className="info-label">Language</span>
<span className="info-value">{site.language || 'N/A'}</span>
<span className="info-label">{t('importAnalysis.language')}</span>
<span className="info-value">{site.language || t('importAnalysis.notAvailable')}</span>
</div>
<div className="import-site-info-item">
<span className="info-label">File</span>
<span className="info-label">{t('importAnalysis.file')}</span>
<span className="info-value">{sourceFile.split(/[/\\]/).pop()}</span>
</div>
</div>
);
};
const StatCards: React.FC<{ report: AnalysisReport }> = ({ report }) => {
const { t } = useI18n();
// Split posts by type
const postsOnly = report.posts.items.filter(i => i.wxrPost.postType === 'post');
const otherPosts = report.posts.items.filter(i => i.wxrPost.postType !== 'post');
@@ -751,80 +758,80 @@ const StatCards: React.FC<{ report: AnalysisReport }> = ({ report }) => {
return (
<div className="import-stat-cards">
<div className="import-stat-card">
<h3>Posts</h3>
<h3>{t('importAnalysis.posts')}</h3>
<div className="import-stat-number">{postsStats.total}</div>
<div className="import-stat-breakdown">
{postsStats.new > 0 && <span className="import-stat-tag stat-new">{postsStats.new} new</span>}
{postsStats.updates > 0 && <span className="import-stat-tag stat-update">{postsStats.updates} update</span>}
{postsStats.conflicts > 0 && <span className="import-stat-tag stat-conflict">{postsStats.conflicts} conflict</span>}
{postsStats.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{postsStats.contentDuplicates} duplicate</span>}
{postsStats.new > 0 && <span className="import-stat-tag stat-new">{postsStats.new} {t('importAnalysis.new')}</span>}
{postsStats.updates > 0 && <span className="import-stat-tag stat-update">{postsStats.updates} {t('importAnalysis.update')}</span>}
{postsStats.conflicts > 0 && <span className="import-stat-tag stat-conflict">{postsStats.conflicts} {t('importAnalysis.conflict')}</span>}
{postsStats.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{postsStats.contentDuplicates} {t('importAnalysis.duplicate')}</span>}
</div>
</div>
{otherStats.total > 0 && (
<div className="import-stat-card">
<h3 title={otherTypes}>Other</h3>
<h3 title={otherTypes}>{t('importAnalysis.other')}</h3>
<div className="import-stat-number">{otherStats.total}</div>
<div className="import-stat-breakdown">
{otherStats.new > 0 && <span className="import-stat-tag stat-new">{otherStats.new} new</span>}
{otherStats.updates > 0 && <span className="import-stat-tag stat-update">{otherStats.updates} update</span>}
{otherStats.conflicts > 0 && <span className="import-stat-tag stat-conflict">{otherStats.conflicts} conflict</span>}
{otherStats.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{otherStats.contentDuplicates} duplicate</span>}
{otherStats.new > 0 && <span className="import-stat-tag stat-new">{otherStats.new} {t('importAnalysis.new')}</span>}
{otherStats.updates > 0 && <span className="import-stat-tag stat-update">{otherStats.updates} {t('importAnalysis.update')}</span>}
{otherStats.conflicts > 0 && <span className="import-stat-tag stat-conflict">{otherStats.conflicts} {t('importAnalysis.conflict')}</span>}
{otherStats.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{otherStats.contentDuplicates} {t('importAnalysis.duplicate')}</span>}
</div>
</div>
)}
<div className="import-stat-card">
<h3>Pages</h3>
<h3>{t('importAnalysis.pages')}</h3>
<div className="import-stat-number">{report.pages.total}</div>
<div className="import-stat-breakdown">
{report.pages.new > 0 && <span className="import-stat-tag stat-new">{report.pages.new} new</span>}
{report.pages.updates > 0 && <span className="import-stat-tag stat-update">{report.pages.updates} update</span>}
{report.pages.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.pages.conflicts} conflict</span>}
{report.pages.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.pages.contentDuplicates} duplicate</span>}
{report.pages.new > 0 && <span className="import-stat-tag stat-new">{report.pages.new} {t('importAnalysis.new')}</span>}
{report.pages.updates > 0 && <span className="import-stat-tag stat-update">{report.pages.updates} {t('importAnalysis.update')}</span>}
{report.pages.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.pages.conflicts} {t('importAnalysis.conflict')}</span>}
{report.pages.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.pages.contentDuplicates} {t('importAnalysis.duplicate')}</span>}
</div>
</div>
<div className="import-stat-card">
<h3>Media</h3>
<h3>{t('importAnalysis.media')}</h3>
<div className="import-stat-number">{report.media.total}</div>
<div className="import-stat-breakdown">
{report.media.new > 0 && <span className="import-stat-tag stat-new">{report.media.new} new</span>}
{report.media.updates > 0 && <span className="import-stat-tag stat-update">{report.media.updates} update</span>}
{report.media.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.media.conflicts} conflict</span>}
{report.media.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.media.contentDuplicates} duplicate</span>}
{report.media.missing > 0 && <span className="import-stat-tag stat-missing">{report.media.missing} missing</span>}
{report.media.new > 0 && <span className="import-stat-tag stat-new">{report.media.new} {t('importAnalysis.new')}</span>}
{report.media.updates > 0 && <span className="import-stat-tag stat-update">{report.media.updates} {t('importAnalysis.update')}</span>}
{report.media.conflicts > 0 && <span className="import-stat-tag stat-conflict">{report.media.conflicts} {t('importAnalysis.conflict')}</span>}
{report.media.contentDuplicates > 0 && <span className="import-stat-tag stat-duplicate">{report.media.contentDuplicates} {t('importAnalysis.duplicate')}</span>}
{report.media.missing > 0 && <span className="import-stat-tag stat-missing">{report.media.missing} {t('importAnalysis.missing')}</span>}
</div>
</div>
<div className="import-stat-card">
<h3>Categories</h3>
<h3>{t('importAnalysis.categories')}</h3>
<div className="import-stat-number">{report.categories.length}</div>
<div className="import-stat-breakdown">
{report.categories.filter(c => c.existsInProject).length > 0 && (
<span className="import-stat-tag stat-update">{report.categories.filter(c => c.existsInProject).length} existing</span>
<span className="import-stat-tag stat-update">{report.categories.filter(c => c.existsInProject).length} {t('importAnalysis.existing')}</span>
)}
{report.categories.filter(c => !c.existsInProject && c.mappedTo).length > 0 && (
<span className="import-stat-tag stat-mapped">{report.categories.filter(c => !c.existsInProject && c.mappedTo).length} mapped</span>
<span className="import-stat-tag stat-mapped">{report.categories.filter(c => !c.existsInProject && c.mappedTo).length} {t('importAnalysis.mapped')}</span>
)}
{report.categories.filter(c => !c.existsInProject && !c.mappedTo).length > 0 && (
<span className="import-stat-tag stat-new">{report.categories.filter(c => !c.existsInProject && !c.mappedTo).length} new</span>
<span className="import-stat-tag stat-new">{report.categories.filter(c => !c.existsInProject && !c.mappedTo).length} {t('importAnalysis.new')}</span>
)}
</div>
</div>
<div className="import-stat-card">
<h3>Tags</h3>
<h3>{t('importAnalysis.tags')}</h3>
<div className="import-stat-number">{report.tags.length}</div>
<div className="import-stat-breakdown">
{report.tags.filter(t => t.existsInProject).length > 0 && (
<span className="import-stat-tag stat-update">{report.tags.filter(t => t.existsInProject).length} existing</span>
<span className="import-stat-tag stat-update">{report.tags.filter(t => t.existsInProject).length} {t('importAnalysis.existing')}</span>
)}
{report.tags.filter(t => !t.existsInProject && t.mappedTo).length > 0 && (
<span className="import-stat-tag stat-mapped">{report.tags.filter(t => !t.existsInProject && t.mappedTo).length} mapped</span>
<span className="import-stat-tag stat-mapped">{report.tags.filter(t => !t.existsInProject && t.mappedTo).length} {t('importAnalysis.mapped')}</span>
)}
{report.tags.filter(t => !t.existsInProject && !t.mappedTo).length > 0 && (
<span className="import-stat-tag stat-new">{report.tags.filter(t => !t.existsInProject && !t.mappedTo).length} new</span>
<span className="import-stat-tag stat-new">{report.tags.filter(t => !t.existsInProject && !t.mappedTo).length} {t('importAnalysis.new')}</span>
)}
</div>
</div>
@@ -838,6 +845,7 @@ interface DateDistribution {
}
const DateDistributionCard: React.FC<{ distribution: DateDistribution }> = ({ distribution }) => {
const { t } = useI18n();
const postYears = Object.keys(distribution.posts).map(Number).sort();
const mediaYears = Object.keys(distribution.media).map(Number).sort();
const allYears = [...new Set([...postYears, ...mediaYears])].sort();
@@ -853,12 +861,12 @@ const DateDistributionCard: React.FC<{ distribution: DateDistribution }> = ({ di
return (
<div className="import-date-distribution">
<h3>Date Distribution</h3>
<h3>{t('importAnalysis.dateDistribution')}</h3>
<div className="distribution-grid">
<div className="distribution-column">
<div className="distribution-header">
<span className="distribution-label">Posts/Pages</span>
<span className="distribution-total">{totalPosts} total</span>
<span className="distribution-label">{t('importAnalysis.postsPages')}</span>
<span className="distribution-total">{totalPosts} {t('importAnalysis.total')}</span>
</div>
<div className="distribution-bars">
{allYears.map(year => {
@@ -881,8 +889,8 @@ const DateDistributionCard: React.FC<{ distribution: DateDistribution }> = ({ di
</div>
<div className="distribution-column">
<div className="distribution-header">
<span className="distribution-label">Media</span>
<span className="distribution-total">{totalMedia} total</span>
<span className="distribution-label">{t('importAnalysis.media')}</span>
<span className="distribution-total">{totalMedia} {t('importAnalysis.total')}</span>
</div>
<div className="distribution-bars">
{allYears.map(year => {
@@ -909,22 +917,22 @@ const DateDistributionCard: React.FC<{ distribution: DateDistribution }> = ({ di
};
// Helper function to format post metadata for tooltip (new post from WXR)
function formatPostTooltip(wxrPost: AnalyzedPostItem['wxrPost']): string {
function formatPostTooltip(wxrPost: AnalyzedPostItem['wxrPost'], t: (key: string, params?: Record<string, unknown>) => string): string {
const lines: string[] = [];
lines.push(`WordPress ID: ${wxrPost.wpId}`);
lines.push(`Type: ${wxrPost.postType}`);
lines.push(`Author: ${wxrPost.creator || 'Unknown'}`);
lines.push(`${t('importAnalysis.wordpressId')}: ${wxrPost.wpId}`);
lines.push(`${t('importAnalysis.type')}: ${wxrPost.postType}`);
lines.push(`${t('importAnalysis.author')}: ${wxrPost.creator || t('importAnalysis.unknown')}`);
if (wxrPost.pubDate) {
lines.push(`Published: ${new Date(wxrPost.pubDate).toLocaleDateString()}`);
lines.push(`${t('importAnalysis.published')}: ${new Date(wxrPost.pubDate).toLocaleDateString()}`);
}
if (wxrPost.excerpt) {
const shortExcerpt = wxrPost.excerpt.length > 100
? wxrPost.excerpt.substring(0, 100) + '...'
: wxrPost.excerpt;
lines.push(`Excerpt: ${shortExcerpt}`);
lines.push(`${t('importAnalysis.excerpt')}: ${shortExcerpt}`);
}
if (wxrPost.tags.length > 0) {
lines.push(`Tags: ${wxrPost.tags.join(', ')}`);
lines.push(`${t('importAnalysis.tags')}: ${wxrPost.tags.join(', ')}`);
}
return lines.join('\n');
}
@@ -937,6 +945,7 @@ function PostHoverCard({ children, className, metadata, contentPreview, onHover
contentPreview?: string | null;
onHover?: () => void;
}) {
const { t } = useI18n();
const [visible, setVisible] = useState(false);
const [pos, setPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const triggerRef = useRef<HTMLSpanElement>(null);
@@ -975,17 +984,17 @@ function PostHoverCard({ children, className, metadata, contentPreview, onHover
<div className="post-hover-card" style={{ position: 'fixed', top: pos.top, left: pos.left }}>
<div className="post-hover-title">{metadata.title}</div>
<div className="post-hover-meta">
{metadata.author && <span>Author: {metadata.author}</span>}
{metadata.pubDate && <span>Published: {new Date(metadata.pubDate).toLocaleDateString()}</span>}
{metadata.categories && metadata.categories.length > 0 && <span>Categories: {metadata.categories.join(', ')}</span>}
{metadata.tags && metadata.tags.length > 0 && <span>Tags: {metadata.tags.join(', ')}</span>}
{metadata.excerpt && <span>Excerpt: {metadata.excerpt.length > 100 ? metadata.excerpt.substring(0, 100) + '...' : metadata.excerpt}</span>}
{metadata.author && <span>{t('importAnalysis.author')}: {metadata.author}</span>}
{metadata.pubDate && <span>{t('importAnalysis.published')}: {new Date(metadata.pubDate).toLocaleDateString()}</span>}
{metadata.categories && metadata.categories.length > 0 && <span>{t('importAnalysis.categories')}: {metadata.categories.join(', ')}</span>}
{metadata.tags && metadata.tags.length > 0 && <span>{t('importAnalysis.tags')}: {metadata.tags.join(', ')}</span>}
{metadata.excerpt && <span>{t('importAnalysis.excerpt')}: {metadata.excerpt.length > 100 ? metadata.excerpt.substring(0, 100) + '...' : metadata.excerpt}</span>}
</div>
{contentPreview !== undefined && (
<div className="post-hover-content">
<div className="post-hover-content-label">Content</div>
<div className="post-hover-content-label">{t('importAnalysis.content')}</div>
<div className="post-hover-content-text">
{contentPreview ? (contentPreview.substring(0, 200) + (contentPreview.length > 200 ? '...' : '')) : 'Loading...'}
{contentPreview ? (contentPreview.substring(0, 200) + (contentPreview.length > 200 ? '...' : '')) : t('importAnalysis.loading')}
</div>
</div>
)}
@@ -1001,6 +1010,7 @@ function ExistingPostHoverCard({ children, className, postId }: {
className?: string;
postId: string;
}) {
const { t } = useI18n();
const [postData, setPostData] = useState<{
title: string; content: string; author?: string; pubDate?: string;
tags: string[]; categories: string[]; excerpt?: string;
@@ -1029,7 +1039,7 @@ function ExistingPostHoverCard({ children, className, postId }: {
return (
<PostHoverCard
className={className}
metadata={postData || { title: 'Loading...' }}
metadata={postData || { title: t('importAnalysis.loading') }}
contentPreview={loaded ? (postData?.content || null) : undefined}
onHover={handleHover}
>
@@ -1039,22 +1049,22 @@ function ExistingPostHoverCard({ children, className, postId }: {
}
// Helper function to format media metadata for tooltip
function formatMediaTooltip(wxrMedia: AnalyzedMediaItem['wxrMedia']): string {
function formatMediaTooltip(wxrMedia: AnalyzedMediaItem['wxrMedia'], t: (key: string, params?: Record<string, unknown>) => string): string {
const lines: string[] = [];
lines.push(`WordPress ID: ${wxrMedia.wpId}`);
lines.push(`MIME Type: ${wxrMedia.mimeType || 'Unknown'}`);
lines.push(`${t('importAnalysis.wordpressId')}: ${wxrMedia.wpId}`);
lines.push(`${t('importAnalysis.mimeType')}: ${wxrMedia.mimeType || t('importAnalysis.unknown')}`);
if (wxrMedia.pubDate) {
lines.push(`Uploaded: ${new Date(wxrMedia.pubDate).toLocaleDateString()}`);
lines.push(`${t('importAnalysis.uploaded')}: ${new Date(wxrMedia.pubDate).toLocaleDateString()}`);
}
if (wxrMedia.parentId) {
lines.push(`Parent Post ID: ${wxrMedia.parentId}`);
lines.push(`${t('importAnalysis.parentPostId')}: ${wxrMedia.parentId}`);
}
lines.push(`URL: ${wxrMedia.url}`);
lines.push(`${t('importAnalysis.url')}: ${wxrMedia.url}`);
if (wxrMedia.description) {
const shortDesc = wxrMedia.description.length > 100
? wxrMedia.description.substring(0, 100) + '...'
: wxrMedia.description;
lines.push(`Description: ${shortDesc}`);
lines.push(`${t('importAnalysis.description')}: ${shortDesc}`);
}
return lines.join('\n');
}
@@ -1065,70 +1075,74 @@ const ConflictsSection: React.FC<{
expanded: boolean;
onToggle: () => void;
onResolutionChange: (slug: string, resolution: ImportConflictResolution) => void;
}> = ({ title, items, expanded, onToggle, onResolutionChange }) => (
<div className="import-detail-section conflicts-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
{title} ({items.length})
</h3>
{expanded && (
<table className="import-detail-table conflicts-table">
<thead>
<tr>
<th>Slug</th>
<th>New Entry (WXR)</th>
<th>Existing Entry</th>
<th>Resolution</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx} className="conflict-row">
<td className="slug-cell">{item.wxrPost.slug}</td>
<td className="new-entry-cell">
<PostHoverCard
className="entry-title tooltip-target"
metadata={{ title: item.wxrPost.title, author: item.wxrPost.creator, pubDate: item.wxrPost.pubDate, categories: item.wxrPost.categories, tags: item.wxrPost.tags }}
contentPreview={item.markdownPreview}
>
{item.wxrPost.title}
</PostHoverCard>
{item.wxrPost.categories.length > 0 && (
<span className="entry-categories">
{item.wxrPost.categories.join(', ')}
</span>
)}
</td>
<td className="existing-entry-cell">
{item.existingPost ? (
<ExistingPostHoverCard
className="entry-title tooltip-target"
postId={item.existingPost.id}
>
{item.existingPost.title}
</ExistingPostHoverCard>
) : (
<span className="entry-title">--</span>
)}
</td>
<td className="resolution-cell">
<select
className="resolution-select"
value={item.conflictResolution || 'ignore'}
onChange={(e) => onResolutionChange(item.wxrPost.slug, e.target.value as ImportConflictResolution)}
>
<option value="ignore">Ignore</option>
<option value="overwrite">Overwrite</option>
<option value="import">Import (new slug)</option>
</select>
</td>
}> = ({ title, items, expanded, onToggle, onResolutionChange }) => {
const { t } = useI18n();
return (
<div className="import-detail-section conflicts-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
{title} ({items.length})
</h3>
{expanded && (
<table className="import-detail-table conflicts-table">
<thead>
<tr>
<th>{t('importAnalysis.slug')}</th>
<th>{t('importAnalysis.newEntryWxr')}</th>
<th>{t('importAnalysis.existingEntry')}</th>
<th>{t('importAnalysis.resolution')}</th>
</tr>
))}
</tbody>
</table>
)}
</div>
);
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx} className="conflict-row">
<td className="slug-cell">{item.wxrPost.slug}</td>
<td className="new-entry-cell">
<PostHoverCard
className="entry-title tooltip-target"
metadata={{ title: item.wxrPost.title, author: item.wxrPost.creator, pubDate: item.wxrPost.pubDate, categories: item.wxrPost.categories, tags: item.wxrPost.tags }}
contentPreview={item.markdownPreview}
>
{item.wxrPost.title}
</PostHoverCard>
{item.wxrPost.categories.length > 0 && (
<span className="entry-categories">
{item.wxrPost.categories.join(', ')}
</span>
)}
</td>
<td className="existing-entry-cell">
{item.existingPost ? (
<ExistingPostHoverCard
className="entry-title tooltip-target"
postId={item.existingPost.id}
>
{item.existingPost.title}
</ExistingPostHoverCard>
) : (
<span className="entry-title">{t('importAnalysis.none')}</span>
)}
</td>
<td className="resolution-cell">
<select
className="resolution-select"
value={item.conflictResolution || 'ignore'}
onChange={(e) => onResolutionChange(item.wxrPost.slug, e.target.value as ImportConflictResolution)}
>
<option value="ignore">{t('importAnalysis.ignore')}</option>
<option value="overwrite">{t('importAnalysis.overwrite')}</option>
<option value="import">{t('importAnalysis.importNewSlug')}</option>
</select>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
const PostDetailSection: React.FC<{
title: string;
@@ -1136,84 +1150,92 @@ const PostDetailSection: React.FC<{
expanded: boolean;
onToggle: () => void;
showType?: boolean;
}> = ({ title, items, expanded, onToggle, showType }) => (
<div className="import-detail-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
{title}
</h3>
{expanded && (
<table className="import-detail-table">
<thead>
<tr>
<th>Status</th>
{showType && <th>Type</th>}
<th>Title</th>
<th>Slug</th>
<th>Categories</th>
<th>WP Status</th>
<th>Existing Match</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx} className="post-row-with-tooltip" title={formatPostTooltip(item.wxrPost)}>
<td><span className={`status-badge ${item.status}`}>{item.status}</span></td>
{showType && <td className="post-type-cell">{item.wxrPost.postType}</td>}
<td>{item.wxrPost.title}</td>
<td className="slug-cell">{item.wxrPost.slug}</td>
<td className="categories-cell">
{item.wxrPost.categories.length > 0
? item.wxrPost.categories.join(', ')
: '--'}
</td>
<td>{item.wxrPost.status}</td>
<td className="existing-match">{item.existingPost?.title || '--'}</td>
}> = ({ title, items, expanded, onToggle, showType }) => {
const { t } = useI18n();
return (
<div className="import-detail-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
{title}
</h3>
{expanded && (
<table className="import-detail-table">
<thead>
<tr>
<th>{t('importAnalysis.status')}</th>
{showType && <th>{t('importAnalysis.type')}</th>}
<th>{t('importAnalysis.title')}</th>
<th>{t('importAnalysis.slug')}</th>
<th>{t('importAnalysis.categories')}</th>
<th>{t('importAnalysis.wpStatus')}</th>
<th>{t('importAnalysis.existingMatch')}</th>
</tr>
))}
</tbody>
</table>
)}
</div>
);
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx} className="post-row-with-tooltip" title={formatPostTooltip(item.wxrPost, t)}>
<td><span className={`status-badge ${item.status}`}>{item.status}</span></td>
{showType && <td className="post-type-cell">{item.wxrPost.postType}</td>}
<td>{item.wxrPost.title}</td>
<td className="slug-cell">{item.wxrPost.slug}</td>
<td className="categories-cell">
{item.wxrPost.categories.length > 0
? item.wxrPost.categories.join(', ')
: t('importAnalysis.none')}
</td>
<td>{item.wxrPost.status}</td>
<td className="existing-match">{item.existingPost?.title || t('importAnalysis.none')}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
const MediaDetailSection: React.FC<{
title: string;
items: AnalyzedMediaItem[];
expanded: boolean;
onToggle: () => void;
}> = ({ title, items, expanded, onToggle }) => (
<div className="import-detail-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
{title}
</h3>
{expanded && (
<table className="import-detail-table">
<thead>
<tr>
<th>Status</th>
<th>Filename</th>
<th>Type</th>
<th>Path</th>
<th>Existing Match</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx} className="media-row-with-tooltip" title={formatMediaTooltip(item.wxrMedia)}>
<td><span className={`status-badge ${item.status}`}>{item.status}</span></td>
<td>{item.wxrMedia.filename}</td>
<td className="mime-type-cell">{item.wxrMedia.mimeType || '--'}</td>
<td className="slug-cell">{item.wxrMedia.relativePath}</td>
<td className="existing-match">{item.existingMedia?.originalName || '--'}</td>
}> = ({ title, items, expanded, onToggle }) => {
const { t } = useI18n();
return (
<div className="import-detail-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
{title}
</h3>
{expanded && (
<table className="import-detail-table">
<thead>
<tr>
<th>{t('importAnalysis.status')}</th>
<th>{t('importAnalysis.filename')}</th>
<th>{t('importAnalysis.type')}</th>
<th>{t('importAnalysis.path')}</th>
<th>{t('importAnalysis.existingMatch')}</th>
</tr>
))}
</tbody>
</table>
)}
</div>
);
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx} className="media-row-with-tooltip" title={formatMediaTooltip(item.wxrMedia, t)}>
<td><span className={`status-badge ${item.status}`}>{item.status}</span></td>
<td>{item.wxrMedia.filename}</td>
<td className="mime-type-cell">{item.wxrMedia.mimeType || t('importAnalysis.none')}</td>
<td className="slug-cell">{item.wxrMedia.relativePath}</td>
<td className="existing-match">{item.existingMedia?.originalName || t('importAnalysis.none')}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
const TaxonomySection: React.FC<{
categories: TaxonomyItem[];

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../i18n';
import './PostSearchModal.css';
interface SearchResult {
@@ -19,6 +20,7 @@ export const PostSearchModal: React.FC<PostSearchModalProps> = ({
onClose,
initialQuery = ''
}) => {
const { t } = useI18n();
const [query, setQuery] = useState(initialQuery);
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -107,7 +109,7 @@ export const PostSearchModal: React.FC<PostSearchModalProps> = ({
ref={inputRef}
type="text"
className="post-search-input"
placeholder="Search posts by title or content..."
placeholder={t('postSearch.placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
@@ -117,19 +119,19 @@ export const PostSearchModal: React.FC<PostSearchModalProps> = ({
<div className="post-search-results">
{isSearching && (
<div className="post-search-loading">
Searching...
{t('postSearch.searching')}
</div>
)}
{!isSearching && query.length < 2 && (
<div className="post-search-empty">
Type at least 2 characters to search
{t('postSearch.typeMore')}
</div>
)}
{!isSearching && query.length >= 2 && results.length === 0 && (
<div className="post-search-empty">
No posts found for "{query}"
{t('postSearch.noResults', { query })}
</div>
)}
@@ -155,7 +157,7 @@ export const PostSearchModal: React.FC<PostSearchModalProps> = ({
<div className="post-search-footer">
<span className="post-search-hint">
Use to navigate, Enter to select, Esc to close
{t('postSearch.hint')}
</span>
</div>
</div>

View File

@@ -484,13 +484,13 @@ export const SettingsView: React.FC = () => {
<SettingSection
id="settings-section-editor"
title={t('settings.editor.title')}
description="Configure the blog post editor behavior and appearance."
description={t('settings.editor.description')}
hidden={!sectionHasMatches(editorKeywords)}
>
<SettingRow
id="editor-mode"
label="Default Editor Mode"
description="Choose the default mode when opening posts. You can switch modes at any time using the editor toolbar."
label={t('settings.editor.defaultModeLabel')}
description={t('settings.editor.defaultModeDescription')}
>
<select
id="editor-mode"
@@ -505,12 +505,12 @@ export const SettingsView: React.FC = () => {
<SettingRow
id="diff-view-style"
label="Diff View Style"
description="Choose how Git diffs are shown by default."
label={t('settings.editor.diffViewStyleLabel')}
description={t('settings.editor.diffViewStyleDescription')}
>
<select
id="diff-view-style"
aria-label="Diff View Style"
aria-label={t('settings.editor.diffViewStyleLabel')}
value={gitDiffPreferences.viewStyle}
onChange={(e) =>
setGitDiffPreferences({
@@ -526,12 +526,12 @@ export const SettingsView: React.FC = () => {
<SettingRow
id="diff-wrap-long-lines"
label="Wrap Long Lines in Diff"
description="Enable word wrapping for long lines in Git diffs."
label={t('settings.editor.wrapLongLinesLabel')}
description={t('settings.editor.wrapLongLinesDescription')}
>
<input
id="diff-wrap-long-lines"
aria-label="Wrap long lines in diff"
aria-label={t('settings.editor.wrapLongLinesAria')}
type="checkbox"
checked={gitDiffPreferences.wordWrap}
onChange={(e) =>
@@ -545,12 +545,12 @@ export const SettingsView: React.FC = () => {
<SettingRow
id="diff-hide-unchanged-regions"
label="Hide Unchanged Regions"
description="Collapse unchanged regions in Git diffs."
label={t('settings.editor.hideUnchangedRegionsLabel')}
description={t('settings.editor.hideUnchangedRegionsDescription')}
>
<input
id="diff-hide-unchanged-regions"
aria-label="Hide unchanged regions"
aria-label={t('settings.editor.hideUnchangedRegionsAria')}
type="checkbox"
checked={gitDiffPreferences.hideUnchangedRegions}
onChange={(e) =>
@@ -717,7 +717,7 @@ export const SettingsView: React.FC = () => {
<div className="category-add-form">
<input
type="text"
placeholder="New category name..."
placeholder={t('settings.content.newCategoryPlaceholder')}
value={newCategoryInput}
onChange={(e) => setNewCategoryInput(e.target.value)}
onKeyDown={(e) => {
@@ -728,13 +728,13 @@ export const SettingsView: React.FC = () => {
}}
/>
<button className="primary" onClick={handleAddCategory}>
Add Category
{t('settings.content.addCategory')}
</button>
</div>
<div className="setting-actions">
<button className="secondary" onClick={handleResetCategories}>
Reset to Defaults
{t('settings.content.resetDefaults')}
</button>
</div>
</SettingSection>
@@ -813,8 +813,8 @@ export const SettingsView: React.FC = () => {
>
<SettingRow
id="ai-api-key"
label="OpenCode API Key"
description="Your API key for the OpenCode Zen gateway. Required to use AI features."
label={t('settings.ai.apiKeyLabel')}
description={t('settings.ai.apiKeyDescription')}
>
<div className="setting-input-group">
{aiHasApiKey ? (
@@ -824,9 +824,9 @@ export const SettingsView: React.FC = () => {
type="text"
value={aiApiKeyMasked}
disabled
placeholder="API key configured"
placeholder={t('settings.ai.apiKeyConfigured')}
/>
<span className="setting-status-badge success"> Configured</span>
<span className="setting-status-badge success">{t('settings.ai.configured')}</span>
</>
) : (
<>
@@ -835,10 +835,10 @@ export const SettingsView: React.FC = () => {
type="password"
value={newApiKey}
onChange={(e) => setNewApiKey(e.target.value)}
placeholder="Enter your API key..."
placeholder={t('chat.apiKeyPlaceholder')}
/>
<button className="primary" onClick={handleSaveApiKey} disabled={!newApiKey.trim()}>
Save Key
{t('chat.apiKeySave')}
</button>
</>
)}
@@ -846,7 +846,7 @@ export const SettingsView: React.FC = () => {
{aiHasApiKey && (
<div className="setting-inline-action">
<button className="text-button" onClick={() => { setAiHasApiKey(false); setAiApiKeyMasked(''); }}>
Change API Key
{t('settings.ai.changeApiKey')}
</button>
</div>
)}
@@ -854,8 +854,8 @@ export const SettingsView: React.FC = () => {
<SettingRow
id="ai-model"
label="Default Model"
description="The AI model to use for new chat conversations."
label={t('settings.ai.defaultModelLabel')}
description={t('settings.ai.defaultModelDescription')}
>
<select
id="ai-model"
@@ -872,8 +872,8 @@ export const SettingsView: React.FC = () => {
<SettingRow
id="ai-system-prompt"
label="System Prompt"
description="Instructions given to the AI at the start of each conversation. This defines how the assistant behaves and what tools it knows about."
label={t('settings.ai.systemPromptLabel')}
description={t('settings.ai.systemPromptDescription')}
>
<textarea
id="ai-system-prompt"
@@ -882,7 +882,7 @@ export const SettingsView: React.FC = () => {
setAiSystemPrompt(e.target.value);
setAiSystemPromptModified(true);
}}
placeholder="Enter system instructions for the AI assistant..."
placeholder={t('settings.ai.systemPromptPlaceholder')}
rows={12}
className="system-prompt-textarea"
/>
@@ -892,10 +892,10 @@ export const SettingsView: React.FC = () => {
onClick={handleSaveSystemPrompt}
disabled={!aiSystemPromptModified}
>
Save Prompt
{t('settings.ai.savePrompt')}
</button>
<button className="secondary" onClick={handleResetSystemPrompt}>
Reset to Default
{t('settings.ai.resetPrompt')}
</button>
</div>
</SettingRow>
@@ -907,18 +907,18 @@ export const SettingsView: React.FC = () => {
<SettingSection
id="settings-section-publishing"
title={t('settings.publishing.ftpTitle')}
description="Configure FTP credentials for publishing your blog to a web server."
description={t('credentials.ftp.description')}
hidden={!sectionHasMatches(publishingKeywords)}
>
<SettingRow
id="ftp-host"
label="Host"
description="The FTP server hostname or IP address."
label={t('credentials.field.host')}
description={t('settings.publishing.ftpHostDescription')}
>
<input
id="ftp-host"
type="text"
placeholder="ftp.example.com"
placeholder={t('credentials.ftp.placeholder.host')}
value={credentials.ftpHost}
onChange={(e) => setCredentials({ ...credentials, ftpHost: e.target.value })}
/>
@@ -926,13 +926,13 @@ export const SettingsView: React.FC = () => {
<SettingRow
id="ftp-user"
label="Username"
description="Your FTP account username."
label={t('credentials.field.username')}
description={t('settings.publishing.ftpUsernameDescription')}
>
<input
id="ftp-user"
type="text"
placeholder="ftp-user"
placeholder={t('credentials.ftp.placeholder.username')}
value={credentials.ftpUser}
onChange={(e) => setCredentials({ ...credentials, ftpUser: e.target.value })}
/>
@@ -940,21 +940,21 @@ export const SettingsView: React.FC = () => {
<SettingRow
id="ftp-password"
label="Password"
description="Your FTP account password."
label={t('credentials.field.password')}
description={t('settings.publishing.ftpPasswordDescription')}
>
<div className="setting-input-group">
<input
id="ftp-password"
type={showSecrets ? 'text' : 'password'}
placeholder="Password"
placeholder={t('credentials.ftp.placeholder.password')}
value={credentials.ftpPassword}
onChange={(e) => setCredentials({ ...credentials, ftpPassword: e.target.value })}
/>
<button
className="setting-toggle-visibility"
onClick={() => setShowSecrets(!showSecrets)}
title={showSecrets ? 'Hide password' : 'Show password'}
title={showSecrets ? t('settings.publishing.hidePassword') : t('settings.publishing.showPassword')}
>
{showSecrets ? '🔒' : '👁'}
</button>
@@ -969,18 +969,18 @@ export const SettingsView: React.FC = () => {
<SettingSection
title={t('settings.publishing.sshTitle')}
description="Configure SSH credentials for secure deployment to your server."
description={t('credentials.ssh.description')}
hidden={!sectionHasMatches(publishingKeywords)}
>
<SettingRow
id="ssh-host"
label="Host"
description="The SSH server hostname or IP address."
label={t('credentials.field.host')}
description={t('settings.publishing.sshHostDescription')}
>
<input
id="ssh-host"
type="text"
placeholder="server.example.com"
placeholder={t('credentials.ssh.placeholder.host')}
value={credentials.sshHost}
onChange={(e) => setCredentials({ ...credentials, sshHost: e.target.value })}
/>
@@ -988,13 +988,13 @@ export const SettingsView: React.FC = () => {
<SettingRow
id="ssh-user"
label="Username"
description="Your SSH account username."
label={t('credentials.field.username')}
description={t('settings.publishing.sshUsernameDescription')}
>
<input
id="ssh-user"
type="text"
placeholder="ssh-user"
placeholder={t('credentials.ssh.placeholder.username')}
value={credentials.sshUser}
onChange={(e) => setCredentials({ ...credentials, sshUser: e.target.value })}
/>
@@ -1002,13 +1002,13 @@ export const SettingsView: React.FC = () => {
<SettingRow
id="ssh-keypath"
label="SSH Key Path"
description="Path to your SSH private key file."
label={t('credentials.field.sshKeyPath')}
description={t('settings.publishing.sshKeyPathDescription')}
>
<input
id="ssh-keypath"
type="text"
placeholder="~/.ssh/id_rsa"
placeholder={t('credentials.ssh.placeholder.keyPath')}
value={credentials.sshKeyPath}
onChange={(e) => setCredentials({ ...credentials, sshKeyPath: e.target.value })}
/>
@@ -1027,13 +1027,13 @@ export const SettingsView: React.FC = () => {
<SettingSection
id="settings-section-data"
title={t('settings.data.title')}
description="Rebuild the local database index from source files. Useful if post or media files were edited externally."
description={t('settings.data.description')}
hidden={!sectionHasMatches(dataKeywords)}
>
<SettingRow
id="rebuild-posts"
label="Rebuild Posts Database"
description="Re-scan all post markdown files and rebuild the database index."
label={t('settings.data.rebuildPostsLabel')}
description={t('settings.data.rebuildPostsDescription')}
>
<button
className="secondary"
@@ -1053,7 +1053,7 @@ export const SettingsView: React.FC = () => {
}
}}
>
Rebuild Posts
{t('settings.data.rebuildPostsAction')}
</button>
</SettingRow>

View File

@@ -1355,6 +1355,7 @@ const SettingsNav: React.FC = () => {
// Chat conversations list
const ChatList: React.FC = () => {
const { t, language } = useI18n();
const { openTab, closeTab } = useAppStore();
const [conversations, setConversations] = useState<ChatConversation[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -1412,7 +1413,7 @@ const ChatList: React.FC = () => {
}
} catch (error) {
console.error('Failed to create conversation:', error);
showToast.error('Failed to create new chat');
showToast.error(t('sidebar.chat.createFailed'));
}
};
@@ -1428,7 +1429,7 @@ const ChatList: React.FC = () => {
closeTab(conversationId);
} catch (error) {
console.error('Failed to delete conversation:', error);
showToast.error('Failed to delete chat');
showToast.error(t('sidebar.chat.deleteFailed'));
}
};
@@ -1436,24 +1437,25 @@ const ChatList: React.FC = () => {
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
if (diffDays === 0) {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
return date.toLocaleTimeString(uiDateLocale, { hour: 'numeric', minute: '2-digit' });
} else if (diffDays === 1) {
return 'Yesterday';
return t('sidebar.chat.yesterday');
} else if (diffDays < 7) {
return date.toLocaleDateString('en-US', { weekday: 'short' });
return date.toLocaleDateString(uiDateLocale, { weekday: 'short' });
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return date.toLocaleDateString(uiDateLocale, { month: 'short', day: 'numeric' });
};
if (isLoading) {
return (
<div className="chat-list">
<div className="chat-list-header">
<span>AI ASSISTANT</span>
<span>{t('sidebar.chat.header')}</span>
</div>
<div className="chat-loading">Loading...</div>
<div className="chat-loading">{t('sidebar.loading')}</div>
</div>
);
}
@@ -1461,22 +1463,22 @@ const ChatList: React.FC = () => {
return (
<div className="chat-list">
<div className="chat-list-header">
<span>AI ASSISTANT</span>
<button className="chat-new-button" onClick={handleNewChat} title="New Chat">
<span>{t('sidebar.chat.header')}</span>
<button className="chat-new-button" onClick={handleNewChat} title={t('sidebar.chat.newChat')}>
+
</button>
</div>
{!isReady && (
<div className="chat-auth-prompt">
<p>API key needed. Open a chat to configure.</p>
<p>{t('sidebar.chat.apiKeyNeeded')}</p>
</div>
)}
<div className="chat-list-items">
{conversations.length === 0 ? (
<div className="chat-empty">
<p>No conversations yet</p>
<p>{t('sidebar.chat.noConversations')}</p>
<button className="chat-start-button" onClick={handleNewChat}>
Start a new chat
{t('sidebar.chat.startNew')}
</button>
</div>
) : (
@@ -1496,7 +1498,7 @@ const ChatList: React.FC = () => {
e.stopPropagation();
handleDeleteChat(conv.id);
}}
title="Delete conversation"
title={t('sidebar.chat.deleteConversation')}
>
×
</button>
@@ -1509,6 +1511,7 @@ const ChatList: React.FC = () => {
};
const ImportList: React.FC = () => {
const { t, language } = useI18n();
const { openTab, closeTab, activeProject } = useAppStore();
const [definitions, setDefinitions] = useState<ImportDefinitionData[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -1560,7 +1563,7 @@ const ImportList: React.FC = () => {
}
} catch (error) {
console.error('Failed to create import definition:', error);
showToast.error('Failed to create import definition');
showToast.error(t('sidebar.import.createFailed'));
}
};
@@ -1576,7 +1579,7 @@ const ImportList: React.FC = () => {
closeTab(definitionId);
} catch (error) {
console.error('Failed to delete import definition:', error);
showToast.error('Failed to delete import definition');
showToast.error(t('sidebar.import.deleteFailed'));
}
};
@@ -1584,23 +1587,24 @@ const ImportList: React.FC = () => {
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
if (diffDays === 0) {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
return date.toLocaleTimeString(uiDateLocale, { hour: 'numeric', minute: '2-digit' });
} else if (diffDays === 1) {
return 'Yesterday';
return t('sidebar.chat.yesterday');
} else if (diffDays < 7) {
return date.toLocaleDateString('en-US', { weekday: 'short' });
return date.toLocaleDateString(uiDateLocale, { weekday: 'short' });
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return date.toLocaleDateString(uiDateLocale, { month: 'short', day: 'numeric' });
};
if (isLoading) {
return (
<div className="chat-list">
<div className="chat-list-header">
<span>IMPORTS</span>
<span>{t('sidebar.import.header')}</span>
</div>
<div className="chat-loading">Loading...</div>
<div className="chat-loading">{t('sidebar.loading')}</div>
</div>
);
}
@@ -1608,17 +1612,17 @@ const ImportList: React.FC = () => {
return (
<div className="chat-list">
<div className="chat-list-header">
<span>IMPORTS</span>
<button className="chat-new-button" onClick={handleNewDefinition} title="New Import Definition">
<span>{t('sidebar.import.header')}</span>
<button className="chat-new-button" onClick={handleNewDefinition} title={t('sidebar.import.newDefinition')}>
+
</button>
</div>
<div className="chat-list-items">
{definitions.length === 0 ? (
<div className="chat-empty">
<p>No import definitions yet</p>
<p>{t('sidebar.import.none')}</p>
<button className="chat-start-button" onClick={handleNewDefinition}>
Create an import definition
{t('sidebar.import.createDefinition')}
</button>
</div>
) : (
@@ -1635,7 +1639,7 @@ const ImportList: React.FC = () => {
<button
className="chat-item-delete"
onClick={(e) => handleDeleteDefinition(e, def.id)}
title="Delete import definition"
title={t('sidebar.import.deleteDefinition')}
>
×
</button>

View File

@@ -68,22 +68,22 @@ export const StatusBar: React.FC = () => {
{/* Stats */}
<div className="status-bar-item">
<span>{totalPosts} posts</span>
<span>{t('statusBar.posts', { count: totalPosts })}</span>
</div>
<div className="status-bar-item">
<span>{media.length} media</span>
<span>{t('statusBar.media', { count: media.length })}</span>
</div>
<div className="status-bar-item theme-badge">
<span>Theme: {activeTheme}</span>
<span>{t('statusBar.theme', { theme: activeTheme })}</span>
</div>
<div className="status-bar-item language-badge">
<span>UI</span>
<span>{t('statusBar.ui')}</span>
<select
className="status-bar-language-select"
data-testid="statusbar-language-select"
aria-label="UI language"
aria-label={t('statusBar.uiLanguage')}
value={language}
onChange={(event) => setLanguage(event.target.value as UiLanguage)}
>

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { showToast } from '../Toast';
import { getContrastColor } from '../../utils/color';
import { subscribeToTagEvents } from '../../utils/tagEventSubscriptions';
import { useI18n } from '../../i18n';
import './TagInput.css';
interface TagData {
@@ -30,6 +31,7 @@ export const TagInput: React.FC<TagInputProps> = ({
disabled = false,
mode = 'tag',
}) => {
const { t } = useI18n();
const [inputValue, setInputValue] = useState('');
const [suggestions, setSuggestions] = useState<TagData[]>([]);
const [allTags, setAllTags] = useState<TagData[]>([]);
@@ -108,7 +110,7 @@ export const TagInput: React.FC<TagInputProps> = ({
if (!normalized) return;
if (value.includes(normalized)) {
showToast.info('Tag already added');
showToast.info(t('tagInput.alreadyAdded'));
return;
}
@@ -143,7 +145,9 @@ export const TagInput: React.FC<TagInputProps> = ({
await window.electronAPI?.tags.create({ name: normalized });
}
addTag(normalized);
showToast.success(`${mode === 'category' ? 'Category' : 'Tag'} "${normalized}" created`);
showToast.success(
t(mode === 'category' ? 'tagInput.createdCategory' : 'tagInput.createdTag', { name: normalized })
);
} catch (error) {
const err = error as Error;
showToast.error(err.message);
@@ -238,7 +242,7 @@ export const TagInput: React.FC<TagInputProps> = ({
className="tag-chip-remove"
onClick={() => removeTag(tagName)}
tabIndex={-1}
aria-label={`Remove ${tagName}`}
aria-label={t('tagInput.remove', { tag: tagName })}
>
×
</button>
@@ -302,7 +306,11 @@ export const TagInput: React.FC<TagInputProps> = ({
onClick={() => createAndAddTag(inputValue.trim())}
>
<span className="tag-suggestion-icon">+</span>
<span>Create {mode === 'category' ? 'category' : 'tag'} "{inputValue.trim()}"</span>
<span>
{t(mode === 'category' ? 'tagInput.createCategory' : 'tagInput.createTag', {
name: inputValue.trim(),
})}
</span>
</button>
)}
</div>

View File

@@ -13,7 +13,7 @@ type WindowControlsOverlayLike = {
};
export const WindowTitleBar: React.FC = () => {
const { language } = useI18n();
const { language, t } = useI18n();
const { sidebarVisible, panelVisible, toggleSidebar, togglePanel } = useAppStore();
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
@@ -430,9 +430,9 @@ export const WindowTitleBar: React.FC = () => {
<div className="window-titlebar-actions">
<button
className="window-titlebar-action-button"
aria-label="Toggle Sidebar"
aria-label={t('windowTitleBar.toggleSidebar')}
onClick={toggleSidebar}
title={`${sidebarVisible ? 'Hide' : 'Show'} Sidebar (Ctrl+B)`}
title={sidebarVisible ? t('windowTitleBar.hideSidebar') : t('windowTitleBar.showSidebar')}
>
<span
className={`window-titlebar-sidebar-icon ${sidebarVisible ? 'is-active' : 'is-inactive'}`}
@@ -444,9 +444,9 @@ export const WindowTitleBar: React.FC = () => {
</button>
<button
className="window-titlebar-action-button"
aria-label="Toggle Panel"
aria-label={t('windowTitleBar.togglePanel')}
onClick={togglePanel}
title={`${panelVisible ? 'Hide' : 'Show'} Panel (Ctrl+J)`}
title={panelVisible ? t('windowTitleBar.hidePanel') : t('windowTitleBar.showPanel')}
>
<span
className={`window-titlebar-panel-icon ${panelVisible ? 'is-active' : 'is-inactive'}`}

View File

@@ -556,5 +556,216 @@
"panel.error.loadPostLinks": "Beitragslinks konnten nicht geladen werden.",
"panel.error.loadGitLog": "Git-Log konnte nicht geladen werden.",
"panel.direction.from": "von",
"panel.direction.to": "zu"
"panel.direction.to": "zu",
"settings.editor.description": "Konfiguriere Verhalten und Darstellung des Beitragseditors.",
"settings.editor.defaultModeLabel": "Standard-Editor-Modus",
"settings.editor.defaultModeDescription": "Wähle den Standardmodus beim Öffnen von Beiträgen. Du kannst den Modus jederzeit über die Editorleiste wechseln.",
"settings.editor.diffViewStyleLabel": "Diff-Ansichtsstil",
"settings.editor.diffViewStyleDescription": "Wähle, wie Git-Diffs standardmäßig angezeigt werden.",
"settings.editor.wrapLongLinesLabel": "Lange Zeilen im Diff umbrechen",
"settings.editor.wrapLongLinesDescription": "Aktiviert Zeilenumbruch für lange Zeilen in Git-Diffs.",
"settings.editor.wrapLongLinesAria": "Lange Zeilen im Diff umbrechen",
"settings.editor.hideUnchangedRegionsLabel": "Unveränderte Bereiche ausblenden",
"settings.editor.hideUnchangedRegionsDescription": "Blendet unveränderte Bereiche in Git-Diffs ein.",
"settings.editor.hideUnchangedRegionsAria": "Unveränderte Bereiche ausblenden",
"settings.content.newCategoryPlaceholder": "Neuer Kategoriename...",
"settings.content.addCategory": "Kategorie hinzufügen",
"settings.content.resetDefaults": "Auf Standard zurücksetzen",
"settings.ai.apiKeyLabel": "OpenCode-API-Schlüssel",
"settings.ai.apiKeyDescription": "Dein API-Schlüssel für das OpenCode-Zen-Gateway. Für KI-Funktionen erforderlich.",
"settings.ai.apiKeyConfigured": "API-Schlüssel konfiguriert",
"settings.ai.configured": "✓ Konfiguriert",
"settings.ai.changeApiKey": "API-Schlüssel ändern",
"settings.ai.defaultModelLabel": "Standardmodell",
"settings.ai.defaultModelDescription": "Das KI-Modell für neue Chat-Unterhaltungen.",
"settings.ai.systemPromptLabel": "System-Prompt",
"settings.ai.systemPromptDescription": "Anweisungen für die KI zu Beginn jeder Unterhaltung. Sie bestimmen Verhalten und verfügbare Werkzeuge.",
"settings.ai.systemPromptPlaceholder": "Systemanweisungen für den KI-Assistenten eingeben...",
"settings.ai.savePrompt": "Prompt speichern",
"settings.ai.resetPrompt": "Auf Standard zurücksetzen",
"settings.publishing.ftpHostDescription": "Hostname oder IP-Adresse des FTP-Servers.",
"settings.publishing.ftpUsernameDescription": "Benutzername deines FTP-Kontos.",
"settings.publishing.ftpPasswordDescription": "Passwort deines FTP-Kontos.",
"settings.publishing.showPassword": "Passwort anzeigen",
"settings.publishing.hidePassword": "Passwort verbergen",
"settings.publishing.sshHostDescription": "Hostname oder IP-Adresse des SSH-Servers.",
"settings.publishing.sshUsernameDescription": "Benutzername deines SSH-Kontos.",
"settings.publishing.sshKeyPathDescription": "Pfad zu deiner privaten SSH-Schlüsseldatei.",
"settings.data.description": "Baut den lokalen Datenbankindex aus den Quelldateien neu auf. Nützlich bei extern bearbeiteten Dateien.",
"settings.data.rebuildPostsLabel": "Beitragsdatenbank neu aufbauen",
"settings.data.rebuildPostsDescription": "Alle Markdown-Beiträge neu scannen und den Datenbankindex neu aufbauen.",
"settings.data.rebuildPostsAction": "Beiträge neu aufbauen",
"sidebar.chat.header": "KI-ASSISTENT",
"sidebar.chat.newChat": "Neuer Chat",
"sidebar.chat.apiKeyNeeded": "API-Schlüssel erforderlich. Öffne einen Chat zur Konfiguration.",
"sidebar.chat.noConversations": "Noch keine Unterhaltungen",
"sidebar.chat.startNew": "Neuen Chat starten",
"sidebar.chat.deleteConversation": "Unterhaltung löschen",
"sidebar.chat.createFailed": "Neuer Chat konnte nicht erstellt werden",
"sidebar.chat.deleteFailed": "Chat konnte nicht gelöscht werden",
"sidebar.chat.yesterday": "Gestern",
"sidebar.import.header": "IMPORTE",
"sidebar.import.newDefinition": "Neue Importdefinition",
"sidebar.import.none": "Noch keine Importdefinitionen",
"sidebar.import.createDefinition": "Eine Importdefinition erstellen",
"sidebar.import.deleteDefinition": "Importdefinition löschen",
"sidebar.import.createFailed": "Importdefinition konnte nicht erstellt werden",
"sidebar.import.deleteFailed": "Importdefinition konnte nicht gelöscht werden",
"editor.error.saveTitle": "Speichern fehlgeschlagen",
"editor.error.saveMessage": "Beitrag konnte nicht gespeichert werden",
"editor.error.publishTitle": "Veröffentlichen fehlgeschlagen",
"editor.error.publishMessage": "Beitrag konnte nicht veröffentlicht werden",
"editor.error.discardTitle": "Verwerfen fehlgeschlagen",
"editor.error.deleteTitle": "Löschen fehlgeschlagen",
"editor.error.operationMessage": "Vorgang fehlgeschlagen",
"editor.error.deletePostMessage": "Beitrag konnte nicht gelöscht werden",
"editor.error.fetchPostReferencesMessage": "Beitragsreferenzen konnten nicht geladen werden",
"editor.confirm.discardChanges": "Alle Änderungen seit der letzten Veröffentlichung verwerfen? Das kann nicht rückgängig gemacht werden.",
"editor.confirm.deleteDraft": "Diesen Entwurf löschen? Das kann nicht rückgängig gemacht werden.",
"editor.toast.published": "Beitrag veröffentlicht",
"editor.toast.reverted": "Auf letzte veröffentlichte Version zurückgesetzt",
"editor.toast.draftDeleted": "Entwurf gelöscht",
"editor.toast.postDeleted": "Beitrag gelöscht",
"editor.media.notFound": "Medium nicht gefunden",
"editor.media.error.analyzeImage": "Bildanalyse fehlgeschlagen",
"editor.media.error.updateTitle": "Aktualisierung fehlgeschlagen",
"editor.media.error.updateMessage": "Medium konnte nicht aktualisiert werden",
"editor.media.error.replaceTitle": "Ersetzen fehlgeschlagen",
"editor.media.error.replaceMessage": "Mediendatei konnte nicht ersetzt werden",
"editor.media.error.deleteMessage": "Medium konnte nicht gelöscht werden",
"editor.media.error.fetchReferencesMessage": "Medienreferenzen konnten nicht geladen werden",
"editor.media.toast.aiApplied": "KI-Vorschläge übernommen",
"editor.media.toast.linkedToPost": "Mit Beitrag verknüpft",
"editor.media.toast.linkFailed": "Verknüpfung mit Beitrag fehlgeschlagen",
"editor.media.toast.unlinkedFromPost": "Vom Beitrag getrennt",
"editor.media.toast.unlinkFailed": "Trennen vom Beitrag fehlgeschlagen",
"editor.media.toast.updated": "Medium aktualisiert",
"editor.media.toast.fileReplaced": "Datei ersetzt (Vorschaubilder neu erstellt)",
"editor.media.toast.deleted": "Medium gelöscht",
"editor.media.quickActions.title": "Schnellaktionen",
"editor.media.quickActions.analyzing": "⏳ Analysiere...",
"editor.media.quickActions.button": "⚡ Schnellaktionen",
"editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen",
"editor.media.quickActions.aiDescription": "Analysiert das Bild und schlägt Metadaten vor",
"editor.media.replaceFile": "Datei ersetzen",
"editor.media.field.fileName": "Dateiname",
"editor.media.field.type": "Typ",
"editor.media.field.size": "Größe",
"editor.media.field.dimensions": "Abmessungen",
"editor.media.field.title": "Titel",
"editor.media.field.altText": "Alternativtext",
"editor.media.field.caption": "Bildunterschrift",
"editor.media.field.tags": "Tags (kommagetrennt)",
"editor.media.field.author": "Autor",
"editor.media.placeholder.title": "Titel für Listen und Suchergebnisse",
"editor.media.placeholder.altText": "Bild für Barrierefreiheit beschreiben",
"editor.media.placeholder.caption": "Bildunterschrift",
"editor.media.placeholder.tags": "tag1, tag2, tag3",
"editor.media.placeholder.author": "Autorenname",
"editor.media.linkedPosts": "Verknüpfte Beiträge",
"editor.media.linkToPostTitle": "Mit einem Beitrag verknüpfen",
"editor.media.linkAction": "+ Verknüpfen",
"editor.media.searchPosts": "Beiträge suchen...",
"editor.media.noMatchingPosts": "Keine passenden Beiträge",
"editor.media.noPostsToLink": "Keine verknüpfbaren Beiträge verfügbar",
"editor.media.morePosts": "+{count} weitere Beiträge",
"editor.media.notLinked": "Mit keinem Beitrag verknüpft",
"editor.media.openPost": "Beitrag öffnen",
"editor.media.unlinkFromPost": "Vom Beitrag trennen",
"postSearch.placeholder": "Beiträge nach Titel oder Inhalt durchsuchen...",
"postSearch.searching": "Suche...",
"postSearch.typeMore": "Mindestens 2 Zeichen zum Suchen eingeben",
"postSearch.noResults": "Keine Beiträge für \"{query}\" gefunden",
"postSearch.hint": "Mit ↑↓ navigieren, Enter auswählen, Esc schließen",
"statusBar.posts": "{count} Beiträge",
"statusBar.media": "{count} Medien",
"statusBar.theme": "Theme: {theme}",
"statusBar.ui": "UI",
"statusBar.uiLanguage": "UI-Sprache",
"windowTitleBar.toggleSidebar": "Seitenleiste umschalten",
"windowTitleBar.hideSidebar": "Seitenleiste ausblenden (Ctrl+B)",
"windowTitleBar.showSidebar": "Seitenleiste anzeigen (Ctrl+B)",
"windowTitleBar.togglePanel": "Panel umschalten",
"windowTitleBar.hidePanel": "Panel ausblenden (Ctrl+J)",
"windowTitleBar.showPanel": "Panel anzeigen (Ctrl+J)",
"tagInput.alreadyAdded": "Tag bereits hinzugefügt",
"tagInput.remove": "{tag} entfernen",
"tagInput.createdTag": "Tag \"{name}\" erstellt",
"tagInput.createdCategory": "Kategorie \"{name}\" erstellt",
"tagInput.createTag": "Tag \"{name}\" erstellen",
"tagInput.createCategory": "Kategorie \"{name}\" erstellen",
"importAnalysis.loadingDefinition": "Importdefinition wird geladen...",
"importAnalysis.namePlaceholder": "Importname...",
"importAnalysis.headerDescription": "Wähle eine WordPress-Exportdatei (WXR) und einen Upload-Ordner, um den Import zu analysieren.",
"importAnalysis.uploadsFolder": "Uploads-Ordner",
"importAnalysis.noFolderSelected": "Kein Ordner ausgewählt",
"importAnalysis.wxrFile": "WXR-Datei",
"importAnalysis.selectFileToAnalyze": "Datei zur Analyse auswählen",
"importAnalysis.analyzing": "Analysiere...",
"importAnalysis.selectAndAnalyze": "Auswählen & analysieren",
"importAnalysis.analyzingWxr": "WXR-Datei wird analysiert...",
"importAnalysis.emptyState": "Wähle eine WordPress-Exportdatei, um die Analyse zu starten.",
"importAnalysis.importing": "Import läuft...",
"importAnalysis.importComplete": "Import erfolgreich abgeschlossen!",
"importAnalysis.importFailed": "Import fehlgeschlagen: {error}",
"importAnalysis.untitledImport": "Unbenannter Import",
"importAnalysis.executionStarting": "Starte...",
"importAnalysis.unknownError": "Unbekannter Fehler",
"importAnalysis.readyToImport": "Bereit zum Import:",
"importAnalysis.tagsCategories": "Tags/Kategorien",
"importAnalysis.posts": "Beiträge",
"importAnalysis.media": "Medien",
"importAnalysis.pages": "Seiten",
"importAnalysis.nothingToImport": "Nichts zu importieren",
"importAnalysis.importItems": "{count} Elemente importieren",
"importAnalysis.postSlugConflicts": "Beitrags-Slug-Konflikte",
"importAnalysis.pageSlugConflicts": "Seiten-Slug-Konflikte",
"importAnalysis.postsWithCount": "Beiträge ({count})",
"importAnalysis.otherWithCount": "Andere ({count})",
"importAnalysis.pagesWithCount": "Seiten ({count})",
"importAnalysis.mediaWithCount": "Medien ({count})",
"importAnalysis.site": "Website",
"importAnalysis.untitled": "Ohne Titel",
"importAnalysis.url": "URL",
"importAnalysis.language": "Sprache",
"importAnalysis.file": "Datei",
"importAnalysis.notAvailable": "k. A.",
"importAnalysis.new": "neu",
"importAnalysis.update": "Aktualisierung",
"importAnalysis.conflict": "Konflikt",
"importAnalysis.duplicate": "Duplikat",
"importAnalysis.missing": "fehlend",
"importAnalysis.categories": "Kategorien",
"importAnalysis.existing": "vorhanden",
"importAnalysis.mapped": "zugeordnet",
"importAnalysis.tags": "Tags",
"importAnalysis.dateDistribution": "Datumsverteilung",
"importAnalysis.postsPages": "Beiträge/Seiten",
"importAnalysis.total": "gesamt",
"importAnalysis.wordpressId": "WordPress-ID",
"importAnalysis.type": "Typ",
"importAnalysis.author": "Autor",
"importAnalysis.unknown": "Unbekannt",
"importAnalysis.published": "Veröffentlicht",
"importAnalysis.excerpt": "Auszug",
"importAnalysis.content": "Inhalt",
"importAnalysis.loading": "Lade...",
"importAnalysis.mimeType": "MIME-Typ",
"importAnalysis.uploaded": "Hochgeladen",
"importAnalysis.parentPostId": "Elternbeitrags-ID",
"importAnalysis.description": "Beschreibung",
"importAnalysis.slug": "Slug",
"importAnalysis.newEntryWxr": "Neuer Eintrag (WXR)",
"importAnalysis.existingEntry": "Vorhandener Eintrag",
"importAnalysis.resolution": "Lösung",
"importAnalysis.ignore": "Ignorieren",
"importAnalysis.overwrite": "Überschreiben",
"importAnalysis.importNewSlug": "Importieren (neuer Slug)",
"importAnalysis.status": "Status",
"importAnalysis.title": "Titel",
"importAnalysis.wpStatus": "WP-Status",
"importAnalysis.existingMatch": "Vorhandene Übereinstimmung",
"importAnalysis.none": "--",
"importAnalysis.filename": "Dateiname",
"importAnalysis.path": "Pfad"
}

View File

@@ -556,5 +556,216 @@
"panel.error.loadPostLinks": "Failed to load post links.",
"panel.error.loadGitLog": "Failed to load git log.",
"panel.direction.from": "from",
"panel.direction.to": "to"
"panel.direction.to": "to",
"settings.editor.description": "Configure the blog post editor behavior and appearance.",
"settings.editor.defaultModeLabel": "Default Editor Mode",
"settings.editor.defaultModeDescription": "Choose the default mode when opening posts. You can switch modes at any time using the editor toolbar.",
"settings.editor.diffViewStyleLabel": "Diff View Style",
"settings.editor.diffViewStyleDescription": "Choose how Git diffs are shown by default.",
"settings.editor.wrapLongLinesLabel": "Wrap Long Lines in Diff",
"settings.editor.wrapLongLinesDescription": "Enable word wrapping for long lines in Git diffs.",
"settings.editor.wrapLongLinesAria": "Wrap long lines in diff",
"settings.editor.hideUnchangedRegionsLabel": "Hide Unchanged Regions",
"settings.editor.hideUnchangedRegionsDescription": "Collapse unchanged regions in Git diffs.",
"settings.editor.hideUnchangedRegionsAria": "Hide unchanged regions",
"settings.content.newCategoryPlaceholder": "New category name...",
"settings.content.addCategory": "Add Category",
"settings.content.resetDefaults": "Reset to Defaults",
"settings.ai.apiKeyLabel": "OpenCode API Key",
"settings.ai.apiKeyDescription": "Your API key for the OpenCode Zen gateway. Required to use AI features.",
"settings.ai.apiKeyConfigured": "API key configured",
"settings.ai.configured": "✓ Configured",
"settings.ai.changeApiKey": "Change API Key",
"settings.ai.defaultModelLabel": "Default Model",
"settings.ai.defaultModelDescription": "The AI model to use for new chat conversations.",
"settings.ai.systemPromptLabel": "System Prompt",
"settings.ai.systemPromptDescription": "Instructions given to the AI at the start of each conversation. This defines how the assistant behaves and what tools it knows about.",
"settings.ai.systemPromptPlaceholder": "Enter system instructions for the AI assistant...",
"settings.ai.savePrompt": "Save Prompt",
"settings.ai.resetPrompt": "Reset to Default",
"settings.publishing.ftpHostDescription": "The FTP server hostname or IP address.",
"settings.publishing.ftpUsernameDescription": "Your FTP account username.",
"settings.publishing.ftpPasswordDescription": "Your FTP account password.",
"settings.publishing.showPassword": "Show password",
"settings.publishing.hidePassword": "Hide password",
"settings.publishing.sshHostDescription": "The SSH server hostname or IP address.",
"settings.publishing.sshUsernameDescription": "Your SSH account username.",
"settings.publishing.sshKeyPathDescription": "Path to your SSH private key file.",
"settings.data.description": "Rebuild the local database index from source files. Useful if post or media files were edited externally.",
"settings.data.rebuildPostsLabel": "Rebuild Posts Database",
"settings.data.rebuildPostsDescription": "Re-scan all post markdown files and rebuild the database index.",
"settings.data.rebuildPostsAction": "Rebuild Posts",
"sidebar.chat.header": "AI ASSISTANT",
"sidebar.chat.newChat": "New Chat",
"sidebar.chat.apiKeyNeeded": "API key needed. Open a chat to configure.",
"sidebar.chat.noConversations": "No conversations yet",
"sidebar.chat.startNew": "Start a new chat",
"sidebar.chat.deleteConversation": "Delete conversation",
"sidebar.chat.createFailed": "Failed to create new chat",
"sidebar.chat.deleteFailed": "Failed to delete chat",
"sidebar.chat.yesterday": "Yesterday",
"sidebar.import.header": "IMPORTS",
"sidebar.import.newDefinition": "New Import Definition",
"sidebar.import.none": "No import definitions yet",
"sidebar.import.createDefinition": "Create an import definition",
"sidebar.import.deleteDefinition": "Delete import definition",
"sidebar.import.createFailed": "Failed to create import definition",
"sidebar.import.deleteFailed": "Failed to delete import definition",
"editor.error.saveTitle": "Save Failed",
"editor.error.saveMessage": "Failed to save post",
"editor.error.publishTitle": "Publish Failed",
"editor.error.publishMessage": "Failed to publish post",
"editor.error.discardTitle": "Discard Failed",
"editor.error.deleteTitle": "Delete Failed",
"editor.error.operationMessage": "Operation failed",
"editor.error.deletePostMessage": "Failed to delete post",
"editor.error.fetchPostReferencesMessage": "Failed to fetch post references",
"editor.confirm.discardChanges": "Discard all changes since last publish? This cannot be undone.",
"editor.confirm.deleteDraft": "Delete this draft? This cannot be undone.",
"editor.toast.published": "Post published",
"editor.toast.reverted": "Reverted to last published version",
"editor.toast.draftDeleted": "Draft deleted",
"editor.toast.postDeleted": "Post deleted",
"editor.media.notFound": "Media not found",
"editor.media.error.analyzeImage": "Failed to analyze image",
"editor.media.error.updateTitle": "Update Failed",
"editor.media.error.updateMessage": "Failed to update media",
"editor.media.error.replaceTitle": "Replace Failed",
"editor.media.error.replaceMessage": "Failed to replace media file",
"editor.media.error.deleteMessage": "Failed to delete media",
"editor.media.error.fetchReferencesMessage": "Failed to fetch media references",
"editor.media.toast.aiApplied": "AI suggestions applied",
"editor.media.toast.linkedToPost": "Linked to post",
"editor.media.toast.linkFailed": "Failed to link to post",
"editor.media.toast.unlinkedFromPost": "Unlinked from post",
"editor.media.toast.unlinkFailed": "Failed to unlink from post",
"editor.media.toast.updated": "Media updated",
"editor.media.toast.fileReplaced": "File replaced (thumbnails regenerated)",
"editor.media.toast.deleted": "Media deleted",
"editor.media.quickActions.title": "Quick Actions",
"editor.media.quickActions.analyzing": "⏳ Analyzing...",
"editor.media.quickActions.button": "⚡ Quick Actions",
"editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption",
"editor.media.quickActions.aiDescription": "Analyzes the image to suggest metadata",
"editor.media.replaceFile": "Replace File",
"editor.media.field.fileName": "File Name",
"editor.media.field.type": "Type",
"editor.media.field.size": "Size",
"editor.media.field.dimensions": "Dimensions",
"editor.media.field.title": "Title",
"editor.media.field.altText": "Alt Text",
"editor.media.field.caption": "Caption",
"editor.media.field.tags": "Tags (comma-separated)",
"editor.media.field.author": "Author",
"editor.media.placeholder.title": "Title for lists and search results",
"editor.media.placeholder.altText": "Describe the image for accessibility",
"editor.media.placeholder.caption": "Image caption",
"editor.media.placeholder.tags": "tag1, tag2, tag3",
"editor.media.placeholder.author": "Author name",
"editor.media.linkedPosts": "Linked Posts",
"editor.media.linkToPostTitle": "Link to a post",
"editor.media.linkAction": "+ Link",
"editor.media.searchPosts": "Search posts...",
"editor.media.noMatchingPosts": "No matching posts",
"editor.media.noPostsToLink": "No posts available to link",
"editor.media.morePosts": "+{count} more posts",
"editor.media.notLinked": "Not linked to any posts",
"editor.media.openPost": "Open post",
"editor.media.unlinkFromPost": "Unlink from post",
"postSearch.placeholder": "Search posts by title or content...",
"postSearch.searching": "Searching...",
"postSearch.typeMore": "Type at least 2 characters to search",
"postSearch.noResults": "No posts found for \"{query}\"",
"postSearch.hint": "Use ↑↓ to navigate, Enter to select, Esc to close",
"statusBar.posts": "{count} posts",
"statusBar.media": "{count} media",
"statusBar.theme": "Theme: {theme}",
"statusBar.ui": "UI",
"statusBar.uiLanguage": "UI language",
"windowTitleBar.toggleSidebar": "Toggle Sidebar",
"windowTitleBar.hideSidebar": "Hide Sidebar (Ctrl+B)",
"windowTitleBar.showSidebar": "Show Sidebar (Ctrl+B)",
"windowTitleBar.togglePanel": "Toggle Panel",
"windowTitleBar.hidePanel": "Hide Panel (Ctrl+J)",
"windowTitleBar.showPanel": "Show Panel (Ctrl+J)",
"tagInput.alreadyAdded": "Tag already added",
"tagInput.remove": "Remove {tag}",
"tagInput.createdTag": "Tag \"{name}\" created",
"tagInput.createdCategory": "Category \"{name}\" created",
"tagInput.createTag": "Create tag \"{name}\"",
"tagInput.createCategory": "Create category \"{name}\"",
"importAnalysis.loadingDefinition": "Loading import definition...",
"importAnalysis.namePlaceholder": "Import name...",
"importAnalysis.headerDescription": "Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.",
"importAnalysis.uploadsFolder": "Uploads Folder",
"importAnalysis.noFolderSelected": "No folder selected",
"importAnalysis.wxrFile": "WXR File",
"importAnalysis.selectFileToAnalyze": "Select a file to analyze",
"importAnalysis.analyzing": "Analyzing...",
"importAnalysis.selectAndAnalyze": "Select & Analyze",
"importAnalysis.analyzingWxr": "Analyzing WXR file...",
"importAnalysis.emptyState": "Select a WordPress export file to begin analysis.",
"importAnalysis.importing": "Importing...",
"importAnalysis.importComplete": "Import completed successfully!",
"importAnalysis.importFailed": "Import failed: {error}",
"importAnalysis.untitledImport": "Untitled Import",
"importAnalysis.executionStarting": "Starting...",
"importAnalysis.unknownError": "Unknown error",
"importAnalysis.readyToImport": "Ready to import:",
"importAnalysis.tagsCategories": "tags/categories",
"importAnalysis.posts": "posts",
"importAnalysis.media": "media",
"importAnalysis.pages": "pages",
"importAnalysis.nothingToImport": "Nothing to Import",
"importAnalysis.importItems": "Import {count} Items",
"importAnalysis.postSlugConflicts": "Post Slug Conflicts",
"importAnalysis.pageSlugConflicts": "Page Slug Conflicts",
"importAnalysis.postsWithCount": "Posts ({count})",
"importAnalysis.otherWithCount": "Other ({count})",
"importAnalysis.pagesWithCount": "Pages ({count})",
"importAnalysis.mediaWithCount": "Media ({count})",
"importAnalysis.site": "Site",
"importAnalysis.untitled": "Untitled",
"importAnalysis.url": "URL",
"importAnalysis.language": "Language",
"importAnalysis.file": "File",
"importAnalysis.notAvailable": "N/A",
"importAnalysis.new": "new",
"importAnalysis.update": "update",
"importAnalysis.conflict": "conflict",
"importAnalysis.duplicate": "duplicate",
"importAnalysis.missing": "missing",
"importAnalysis.categories": "Categories",
"importAnalysis.existing": "existing",
"importAnalysis.mapped": "mapped",
"importAnalysis.tags": "Tags",
"importAnalysis.dateDistribution": "Date Distribution",
"importAnalysis.postsPages": "Posts/Pages",
"importAnalysis.total": "total",
"importAnalysis.wordpressId": "WordPress ID",
"importAnalysis.type": "Type",
"importAnalysis.author": "Author",
"importAnalysis.unknown": "Unknown",
"importAnalysis.published": "Published",
"importAnalysis.excerpt": "Excerpt",
"importAnalysis.content": "Content",
"importAnalysis.loading": "Loading...",
"importAnalysis.mimeType": "MIME Type",
"importAnalysis.uploaded": "Uploaded",
"importAnalysis.parentPostId": "Parent Post ID",
"importAnalysis.description": "Description",
"importAnalysis.slug": "Slug",
"importAnalysis.newEntryWxr": "New Entry (WXR)",
"importAnalysis.existingEntry": "Existing Entry",
"importAnalysis.resolution": "Resolution",
"importAnalysis.ignore": "Ignore",
"importAnalysis.overwrite": "Overwrite",
"importAnalysis.importNewSlug": "Import (new slug)",
"importAnalysis.status": "Status",
"importAnalysis.title": "Title",
"importAnalysis.wpStatus": "WP Status",
"importAnalysis.existingMatch": "Existing Match",
"importAnalysis.none": "--",
"importAnalysis.filename": "Filename",
"importAnalysis.path": "Path"
}

View File

@@ -556,5 +556,216 @@
"panel.error.loadPostLinks": "No se pudieron cargar los enlaces de la entrada.",
"panel.error.loadGitLog": "No se pudo cargar el registro Git.",
"panel.direction.from": "desde",
"panel.direction.to": "hacia"
"panel.direction.to": "hacia",
"settings.editor.description": "Personaliza el comportamiento y la apariencia del editor.",
"settings.editor.defaultModeLabel": "Modo predeterminado",
"settings.editor.defaultModeDescription": "Elige cómo se abre el editor por defecto.",
"settings.editor.diffViewStyleLabel": "Estilo de vista diff",
"settings.editor.diffViewStyleDescription": "Define cómo se muestran las diferencias.",
"settings.editor.wrapLongLinesLabel": "Ajustar líneas largas",
"settings.editor.wrapLongLinesDescription": "Ajusta automáticamente las líneas largas.",
"settings.editor.wrapLongLinesAria": "Activar ajuste de línea",
"settings.editor.hideUnchangedRegionsLabel": "Ocultar regiones sin cambios",
"settings.editor.hideUnchangedRegionsDescription": "Contrae las secciones sin cambios en la vista diff.",
"settings.editor.hideUnchangedRegionsAria": "Activar ocultar regiones sin cambios",
"settings.content.newCategoryPlaceholder": "Nueva categoría",
"settings.content.addCategory": "Añadir categoría",
"settings.content.resetDefaults": "Restablecer valores predeterminados",
"settings.ai.apiKeyLabel": "Clave API",
"settings.ai.apiKeyDescription": "Introduce tu clave API para habilitar funciones de IA.",
"settings.ai.apiKeyConfigured": "Clave API configurada",
"settings.ai.configured": "Configurado",
"settings.ai.changeApiKey": "Cambiar clave API",
"settings.ai.defaultModelLabel": "Modelo predeterminado",
"settings.ai.defaultModelDescription": "Selecciona el modelo de IA usado por defecto.",
"settings.ai.systemPromptLabel": "Prompt del sistema",
"settings.ai.systemPromptDescription": "Define las instrucciones del sistema enviadas al modelo.",
"settings.ai.systemPromptPlaceholder": "Escribe aquí tu prompt del sistema…",
"settings.ai.savePrompt": "Guardar prompt",
"settings.ai.resetPrompt": "Restablecer prompt",
"settings.publishing.ftpHostDescription": "Nombre de host o IP del servidor FTP.",
"settings.publishing.ftpUsernameDescription": "Nombre de usuario de FTP.",
"settings.publishing.ftpPasswordDescription": "Contraseña de FTP.",
"settings.publishing.showPassword": "Mostrar contraseña",
"settings.publishing.hidePassword": "Ocultar contraseña",
"settings.publishing.sshHostDescription": "Nombre de host o IP del servidor SSH.",
"settings.publishing.sshUsernameDescription": "Nombre de usuario de SSH.",
"settings.publishing.sshKeyPathDescription": "Ruta a tu clave privada SSH.",
"settings.data.description": "Gestiona y reconstruye los datos locales.",
"settings.data.rebuildPostsLabel": "Reconstruir índice de publicaciones",
"settings.data.rebuildPostsDescription": "Escanea todas las publicaciones y actualiza el índice de datos.",
"settings.data.rebuildPostsAction": "Reconstruir",
"sidebar.chat.header": "Chat",
"sidebar.chat.newChat": "Nuevo chat",
"sidebar.chat.apiKeyNeeded": "Se necesita una clave API para usar el chat.",
"sidebar.chat.noConversations": "Sin conversaciones",
"sidebar.chat.startNew": "Iniciar una nueva conversación",
"sidebar.chat.deleteConversation": "Eliminar conversación",
"sidebar.chat.createFailed": "No se pudo crear la conversación: {error}",
"sidebar.chat.deleteFailed": "No se pudo eliminar la conversación: {error}",
"sidebar.chat.yesterday": "Ayer",
"sidebar.import.header": "Importación",
"sidebar.import.newDefinition": "Nueva definición",
"sidebar.import.none": "Sin definiciones de importación",
"sidebar.import.createDefinition": "Crear definición",
"sidebar.import.deleteDefinition": "Eliminar definición",
"sidebar.import.createFailed": "No se pudo crear la definición: {error}",
"sidebar.import.deleteFailed": "No se pudo eliminar la definición: {error}",
"editor.error.saveTitle": "Error al guardar",
"editor.error.saveMessage": "No se pudo guardar la publicación: {error}",
"editor.error.publishTitle": "Error al publicar",
"editor.error.publishMessage": "No se pudo publicar la publicación: {error}",
"editor.error.discardTitle": "Error al descartar",
"editor.error.deleteTitle": "Error al eliminar",
"editor.error.operationMessage": "La operación falló: {error}",
"editor.error.deletePostMessage": "No se pudo eliminar la publicación: {error}",
"editor.error.fetchPostReferencesMessage": "No se pudieron obtener las referencias de la publicación: {error}",
"editor.confirm.discardChanges": "¿Descartar los cambios sin guardar?",
"editor.confirm.deleteDraft": "¿Eliminar este borrador?",
"editor.toast.published": "Publicación publicada",
"editor.toast.reverted": "Cambios revertidos",
"editor.toast.draftDeleted": "Borrador eliminado",
"editor.toast.postDeleted": "Publicación eliminada",
"editor.media.notFound": "Medio no encontrado",
"editor.media.error.analyzeImage": "No se pudo analizar la imagen: {error}",
"editor.media.error.updateTitle": "Error al actualizar",
"editor.media.error.updateMessage": "No se pudo actualizar el medio: {error}",
"editor.media.error.replaceTitle": "Error al reemplazar",
"editor.media.error.replaceMessage": "No se pudo reemplazar el archivo de medios: {error}",
"editor.media.error.deleteMessage": "No se pudo eliminar el medio: {error}",
"editor.media.error.fetchReferencesMessage": "No se pudieron obtener las referencias del medio: {error}",
"editor.media.toast.aiApplied": "Sugerencias de IA aplicadas",
"editor.media.toast.linkedToPost": "Medio vinculado a la publicación",
"editor.media.toast.linkFailed": "No se pudo vincular el medio: {error}",
"editor.media.toast.unlinkedFromPost": "Medio desvinculado de la publicación",
"editor.media.toast.unlinkFailed": "No se pudo desvincular el medio: {error}",
"editor.media.toast.updated": "Medio actualizado",
"editor.media.toast.fileReplaced": "Archivo de medios reemplazado",
"editor.media.toast.deleted": "Medio eliminado",
"editor.media.quickActions.title": "Acciones rápidas",
"editor.media.quickActions.analyzing": "🔎 Analizando…",
"editor.media.quickActions.button": "✨ Analizar con IA",
"editor.media.quickActions.aiTitle": "Título sugerido por IA",
"editor.media.quickActions.aiDescription": "Genera automáticamente título, texto alternativo y pie de foto.",
"editor.media.replaceFile": "Reemplazar archivo",
"editor.media.field.fileName": "Nombre de archivo",
"editor.media.field.type": "Tipo",
"editor.media.field.size": "Tamaño",
"editor.media.field.dimensions": "Dimensiones",
"editor.media.field.title": "Título",
"editor.media.field.altText": "Texto alternativo",
"editor.media.field.caption": "Pie de foto",
"editor.media.field.tags": "Etiquetas",
"editor.media.field.author": "Autor",
"editor.media.placeholder.title": "Introduce un título",
"editor.media.placeholder.altText": "Describe la imagen para accesibilidad",
"editor.media.placeholder.caption": "Añadir pie de foto",
"editor.media.placeholder.tags": "Añadir etiquetas",
"editor.media.placeholder.author": "Nombre del autor",
"editor.media.linkedPosts": "Publicaciones vinculadas",
"editor.media.linkToPostTitle": "Vincular a una publicación",
"editor.media.linkAction": "Vincular",
"editor.media.searchPosts": "Buscar publicaciones",
"editor.media.noMatchingPosts": "No hay publicaciones que coincidan con “{query}”",
"editor.media.noPostsToLink": "No hay publicaciones disponibles para vincular",
"editor.media.morePosts": "{count} publicaciones más",
"editor.media.notLinked": "No vinculado",
"editor.media.openPost": "Abrir publicación",
"editor.media.unlinkFromPost": "Desvincular de la publicación",
"postSearch.placeholder": "Buscar publicaciones…",
"postSearch.searching": "Buscando…",
"postSearch.typeMore": "Escribe más caracteres para buscar",
"postSearch.noResults": "Sin resultados para “{query}”",
"postSearch.hint": "Busca por título, slug o contenido",
"statusBar.posts": "Publicaciones",
"statusBar.media": "Medios",
"statusBar.theme": "Tema: {theme}",
"statusBar.ui": "UI",
"statusBar.uiLanguage": "Idioma de la interfaz",
"windowTitleBar.toggleSidebar": "Alternar barra lateral",
"windowTitleBar.hideSidebar": "Ocultar barra lateral",
"windowTitleBar.showSidebar": "Mostrar barra lateral",
"windowTitleBar.togglePanel": "Alternar panel",
"windowTitleBar.hidePanel": "Ocultar panel",
"windowTitleBar.showPanel": "Mostrar panel",
"tagInput.alreadyAdded": "La etiqueta “{tag}” ya está añadida",
"tagInput.remove": "Quitar",
"tagInput.createdTag": "Etiqueta “{tag}” creada",
"tagInput.createdCategory": "Categoría “{name}” creada",
"tagInput.createTag": "Crear etiqueta “{tag}”",
"tagInput.createCategory": "Crear categoría “{name}”",
"importAnalysis.loadingDefinition": "Cargando definición de importación…",
"importAnalysis.namePlaceholder": "Nombre de la definición de importación",
"importAnalysis.headerDescription": "Analiza un archivo WXR antes de importar.",
"importAnalysis.uploadsFolder": "Carpeta uploads",
"importAnalysis.noFolderSelected": "Ninguna carpeta seleccionada",
"importAnalysis.wxrFile": "Archivo WXR",
"importAnalysis.selectFileToAnalyze": "Selecciona un archivo para analizar",
"importAnalysis.analyzing": "Analizando…",
"importAnalysis.selectAndAnalyze": "Seleccionar y analizar",
"importAnalysis.analyzingWxr": "Analizando archivo WXR…",
"importAnalysis.emptyState": "Selecciona un archivo WXR e inicia el análisis.",
"importAnalysis.importing": "Importando…",
"importAnalysis.importComplete": "Importación completada: {count}",
"importAnalysis.importFailed": "La importación falló: {error}",
"importAnalysis.untitledImport": "Importación sin título",
"importAnalysis.executionStarting": "Iniciando...",
"importAnalysis.unknownError": "Error desconocido",
"importAnalysis.readyToImport": "Listo para importar:",
"importAnalysis.tagsCategories": "etiquetas/categorías",
"importAnalysis.posts": "publicaciones",
"importAnalysis.media": "medios",
"importAnalysis.pages": "páginas",
"importAnalysis.nothingToImport": "Nada para importar",
"importAnalysis.importItems": "Importar {count} elementos",
"importAnalysis.postSlugConflicts": "Conflictos de slug de publicaciones",
"importAnalysis.pageSlugConflicts": "Conflictos de slug de páginas",
"importAnalysis.postsWithCount": "Publicaciones ({count})",
"importAnalysis.otherWithCount": "Otros ({count})",
"importAnalysis.pagesWithCount": "Páginas ({count})",
"importAnalysis.mediaWithCount": "Medios ({count})",
"importAnalysis.site": "Sitio",
"importAnalysis.untitled": "Sin título",
"importAnalysis.url": "URL",
"importAnalysis.language": "Idioma",
"importAnalysis.file": "Archivo",
"importAnalysis.notAvailable": "N/D",
"importAnalysis.new": "nuevo",
"importAnalysis.update": "actualización",
"importAnalysis.conflict": "conflicto",
"importAnalysis.duplicate": "duplicado",
"importAnalysis.missing": "faltante",
"importAnalysis.categories": "Categorías",
"importAnalysis.existing": "existente",
"importAnalysis.mapped": "mapeado",
"importAnalysis.tags": "Etiquetas",
"importAnalysis.dateDistribution": "Distribución por fecha",
"importAnalysis.postsPages": "Publicaciones/Páginas",
"importAnalysis.total": "total",
"importAnalysis.wordpressId": "ID de WordPress",
"importAnalysis.type": "Tipo",
"importAnalysis.author": "Autor",
"importAnalysis.unknown": "Desconocido",
"importAnalysis.published": "Publicado",
"importAnalysis.excerpt": "Extracto",
"importAnalysis.content": "Contenido",
"importAnalysis.loading": "Cargando...",
"importAnalysis.mimeType": "Tipo MIME",
"importAnalysis.uploaded": "Subido",
"importAnalysis.parentPostId": "ID de publicación padre",
"importAnalysis.description": "Descripción",
"importAnalysis.slug": "Slug",
"importAnalysis.newEntryWxr": "Nueva entrada (WXR)",
"importAnalysis.existingEntry": "Entrada existente",
"importAnalysis.resolution": "Resolución",
"importAnalysis.ignore": "Ignorar",
"importAnalysis.overwrite": "Sobrescribir",
"importAnalysis.importNewSlug": "Importar (nuevo slug)",
"importAnalysis.status": "Estado",
"importAnalysis.title": "Título",
"importAnalysis.wpStatus": "Estado WP",
"importAnalysis.existingMatch": "Coincidencia existente",
"importAnalysis.none": "--",
"importAnalysis.filename": "Nombre de archivo",
"importAnalysis.path": "Ruta"
}

View File

@@ -556,5 +556,216 @@
"panel.error.loadPostLinks": "Impossible de charger les liens d'articles.",
"panel.error.loadGitLog": "Impossible de charger le journal Git.",
"panel.direction.from": "depuis",
"panel.direction.to": "vers"
"panel.direction.to": "vers",
"settings.editor.description": "Personnalisez le comportement et lapparence de léditeur.",
"settings.editor.defaultModeLabel": "Mode par défaut",
"settings.editor.defaultModeDescription": "Choisissez le mode douverture de léditeur.",
"settings.editor.diffViewStyleLabel": "Style de vue diff",
"settings.editor.diffViewStyleDescription": "Définissez laffichage des différences.",
"settings.editor.wrapLongLinesLabel": "Retour à la ligne",
"settings.editor.wrapLongLinesDescription": "Replier automatiquement les lignes longues.",
"settings.editor.wrapLongLinesAria": "Activer le retour à la ligne",
"settings.editor.hideUnchangedRegionsLabel": "Masquer les zones inchangées",
"settings.editor.hideUnchangedRegionsDescription": "Réduire les sections sans modifications dans la vue diff.",
"settings.editor.hideUnchangedRegionsAria": "Activer le masquage des zones inchangées",
"settings.content.newCategoryPlaceholder": "Nouvelle catégorie",
"settings.content.addCategory": "Ajouter une catégorie",
"settings.content.resetDefaults": "Réinitialiser par défaut",
"settings.ai.apiKeyLabel": "Clé API",
"settings.ai.apiKeyDescription": "Saisissez votre clé API pour activer les fonctionnalités IA.",
"settings.ai.apiKeyConfigured": "Clé API configurée",
"settings.ai.configured": "Configuré",
"settings.ai.changeApiKey": "Changer la clé API",
"settings.ai.defaultModelLabel": "Modèle par défaut",
"settings.ai.defaultModelDescription": "Sélectionnez le modèle IA utilisé par défaut.",
"settings.ai.systemPromptLabel": "Prompt système",
"settings.ai.systemPromptDescription": "Définissez les instructions système envoyées au modèle.",
"settings.ai.systemPromptPlaceholder": "Écrivez votre prompt système ici…",
"settings.ai.savePrompt": "Enregistrer le prompt",
"settings.ai.resetPrompt": "Réinitialiser le prompt",
"settings.publishing.ftpHostDescription": "Nom dhôte ou IP du serveur FTP.",
"settings.publishing.ftpUsernameDescription": "Nom dutilisateur FTP.",
"settings.publishing.ftpPasswordDescription": "Mot de passe FTP.",
"settings.publishing.showPassword": "Afficher le mot de passe",
"settings.publishing.hidePassword": "Masquer le mot de passe",
"settings.publishing.sshHostDescription": "Nom dhôte ou IP du serveur SSH.",
"settings.publishing.sshUsernameDescription": "Nom dutilisateur SSH.",
"settings.publishing.sshKeyPathDescription": "Chemin vers votre clé privée SSH.",
"settings.data.description": "Gérez et réindexez les données locales.",
"settings.data.rebuildPostsLabel": "Reconstruire les index des articles",
"settings.data.rebuildPostsDescription": "Analyse tous les articles et met à jour lindex de données.",
"settings.data.rebuildPostsAction": "Reconstruire",
"sidebar.chat.header": "Chat",
"sidebar.chat.newChat": "Nouveau chat",
"sidebar.chat.apiKeyNeeded": "Une clé API est nécessaire pour utiliser le chat.",
"sidebar.chat.noConversations": "Aucune conversation",
"sidebar.chat.startNew": "Commencer une nouvelle conversation",
"sidebar.chat.deleteConversation": "Supprimer la conversation",
"sidebar.chat.createFailed": "Échec de création de la conversation : {error}",
"sidebar.chat.deleteFailed": "Échec de suppression de la conversation : {error}",
"sidebar.chat.yesterday": "Hier",
"sidebar.import.header": "Import",
"sidebar.import.newDefinition": "Nouvelle définition",
"sidebar.import.none": "Aucune définition dimport",
"sidebar.import.createDefinition": "Créer une définition",
"sidebar.import.deleteDefinition": "Supprimer la définition",
"sidebar.import.createFailed": "Échec de création de la définition : {error}",
"sidebar.import.deleteFailed": "Échec de suppression de la définition : {error}",
"editor.error.saveTitle": "Échec de lenregistrement",
"editor.error.saveMessage": "Impossible denregistrer larticle : {error}",
"editor.error.publishTitle": "Échec de la publication",
"editor.error.publishMessage": "Impossible de publier larticle : {error}",
"editor.error.discardTitle": "Échec de labandon",
"editor.error.deleteTitle": "Échec de la suppression",
"editor.error.operationMessage": "Lopération a échoué : {error}",
"editor.error.deletePostMessage": "Impossible de supprimer larticle : {error}",
"editor.error.fetchPostReferencesMessage": "Impossible de récupérer les références darticle : {error}",
"editor.confirm.discardChanges": "Ignorer les modifications non enregistrées ?",
"editor.confirm.deleteDraft": "Supprimer ce brouillon ?",
"editor.toast.published": "Article publié",
"editor.toast.reverted": "Modifications annulées",
"editor.toast.draftDeleted": "Brouillon supprimé",
"editor.toast.postDeleted": "Article supprimé",
"editor.media.notFound": "Média introuvable",
"editor.media.error.analyzeImage": "Impossible danalyser limage : {error}",
"editor.media.error.updateTitle": "Échec de mise à jour",
"editor.media.error.updateMessage": "Impossible de mettre à jour le média : {error}",
"editor.media.error.replaceTitle": "Échec du remplacement",
"editor.media.error.replaceMessage": "Impossible de remplacer le fichier média : {error}",
"editor.media.error.deleteMessage": "Impossible de supprimer le média : {error}",
"editor.media.error.fetchReferencesMessage": "Impossible de récupérer les références du média : {error}",
"editor.media.toast.aiApplied": "Suggestions IA appliquées",
"editor.media.toast.linkedToPost": "Média lié à larticle",
"editor.media.toast.linkFailed": "Échec de liaison du média : {error}",
"editor.media.toast.unlinkedFromPost": "Média dissocié de larticle",
"editor.media.toast.unlinkFailed": "Échec de dissociation du média : {error}",
"editor.media.toast.updated": "Média mis à jour",
"editor.media.toast.fileReplaced": "Fichier média remplacé",
"editor.media.toast.deleted": "Média supprimé",
"editor.media.quickActions.title": "Actions rapides",
"editor.media.quickActions.analyzing": "🔎 Analyse en cours…",
"editor.media.quickActions.button": "✨ Analyser avec lIA",
"editor.media.quickActions.aiTitle": "Titre suggéré par lIA",
"editor.media.quickActions.aiDescription": "Générez automatiquement un titre, un texte alternatif et une légende.",
"editor.media.replaceFile": "Remplacer le fichier",
"editor.media.field.fileName": "Nom du fichier",
"editor.media.field.type": "Type",
"editor.media.field.size": "Taille",
"editor.media.field.dimensions": "Dimensions",
"editor.media.field.title": "Titre",
"editor.media.field.altText": "Texte alternatif",
"editor.media.field.caption": "Légende",
"editor.media.field.tags": "Tags",
"editor.media.field.author": "Auteur",
"editor.media.placeholder.title": "Saisissez un titre",
"editor.media.placeholder.altText": "Décrivez limage pour laccessibilité",
"editor.media.placeholder.caption": "Ajouter une légende",
"editor.media.placeholder.tags": "Ajouter des tags",
"editor.media.placeholder.author": "Nom de lauteur",
"editor.media.linkedPosts": "Articles liés",
"editor.media.linkToPostTitle": "Lier à un article",
"editor.media.linkAction": "Lier",
"editor.media.searchPosts": "Rechercher des articles",
"editor.media.noMatchingPosts": "Aucun article correspondant à « {query} »",
"editor.media.noPostsToLink": "Aucun article disponible à lier",
"editor.media.morePosts": "{count} autres articles",
"editor.media.notLinked": "Non lié",
"editor.media.openPost": "Ouvrir larticle",
"editor.media.unlinkFromPost": "Dissocier de larticle",
"postSearch.placeholder": "Rechercher des articles…",
"postSearch.searching": "Recherche…",
"postSearch.typeMore": "Tapez plus de caractères pour rechercher",
"postSearch.noResults": "Aucun résultat pour « {query} »",
"postSearch.hint": "Rechercher par titre, slug ou contenu",
"statusBar.posts": "Articles",
"statusBar.media": "Médias",
"statusBar.theme": "Thème : {theme}",
"statusBar.ui": "UI",
"statusBar.uiLanguage": "Langue de linterface",
"windowTitleBar.toggleSidebar": "Basculer la barre latérale",
"windowTitleBar.hideSidebar": "Masquer la barre latérale",
"windowTitleBar.showSidebar": "Afficher la barre latérale",
"windowTitleBar.togglePanel": "Basculer le panneau",
"windowTitleBar.hidePanel": "Masquer le panneau",
"windowTitleBar.showPanel": "Afficher le panneau",
"tagInput.alreadyAdded": "Le tag « {tag} » est déjà ajouté",
"tagInput.remove": "Supprimer",
"tagInput.createdTag": "Tag « {tag} » créé",
"tagInput.createdCategory": "Catégorie « {name} » créée",
"tagInput.createTag": "Créer le tag « {tag} »",
"tagInput.createCategory": "Créer la catégorie « {name} »",
"importAnalysis.loadingDefinition": "Chargement de la définition dimport…",
"importAnalysis.namePlaceholder": "Nom de la définition dimport",
"importAnalysis.headerDescription": "Analysez un fichier WXR avant import.",
"importAnalysis.uploadsFolder": "Dossier duploads",
"importAnalysis.noFolderSelected": "Aucun dossier sélectionné",
"importAnalysis.wxrFile": "Fichier WXR",
"importAnalysis.selectFileToAnalyze": "Sélectionnez un fichier à analyser",
"importAnalysis.analyzing": "Analyse…",
"importAnalysis.selectAndAnalyze": "Sélectionner et analyser",
"importAnalysis.analyzingWxr": "Analyse du fichier WXR…",
"importAnalysis.emptyState": "Sélectionnez un fichier WXR et lancez lanalyse.",
"importAnalysis.importing": "Import en cours…",
"importAnalysis.importComplete": "Import terminé : {count}",
"importAnalysis.importFailed": "Échec de limport : {error}",
"importAnalysis.untitledImport": "Import sans titre",
"importAnalysis.executionStarting": "Démarrage...",
"importAnalysis.unknownError": "Erreur inconnue",
"importAnalysis.readyToImport": "Prêt à importer :",
"importAnalysis.tagsCategories": "tags/catégories",
"importAnalysis.posts": "articles",
"importAnalysis.media": "médias",
"importAnalysis.pages": "pages",
"importAnalysis.nothingToImport": "Rien à importer",
"importAnalysis.importItems": "Importer {count} éléments",
"importAnalysis.postSlugConflicts": "Conflits de slug darticle",
"importAnalysis.pageSlugConflicts": "Conflits de slug de page",
"importAnalysis.postsWithCount": "Articles ({count})",
"importAnalysis.otherWithCount": "Autres ({count})",
"importAnalysis.pagesWithCount": "Pages ({count})",
"importAnalysis.mediaWithCount": "Médias ({count})",
"importAnalysis.site": "Site",
"importAnalysis.untitled": "Sans titre",
"importAnalysis.url": "URL",
"importAnalysis.language": "Langue",
"importAnalysis.file": "Fichier",
"importAnalysis.notAvailable": "N/D",
"importAnalysis.new": "nouveau",
"importAnalysis.update": "mise à jour",
"importAnalysis.conflict": "conflit",
"importAnalysis.duplicate": "doublon",
"importAnalysis.missing": "manquant",
"importAnalysis.categories": "Catégories",
"importAnalysis.existing": "existant",
"importAnalysis.mapped": "mappé",
"importAnalysis.tags": "Tags",
"importAnalysis.dateDistribution": "Répartition par date",
"importAnalysis.postsPages": "Articles/Pages",
"importAnalysis.total": "total",
"importAnalysis.wordpressId": "ID WordPress",
"importAnalysis.type": "Type",
"importAnalysis.author": "Auteur",
"importAnalysis.unknown": "Inconnu",
"importAnalysis.published": "Publié",
"importAnalysis.excerpt": "Extrait",
"importAnalysis.content": "Contenu",
"importAnalysis.loading": "Chargement...",
"importAnalysis.mimeType": "Type MIME",
"importAnalysis.uploaded": "Téléversé",
"importAnalysis.parentPostId": "ID du post parent",
"importAnalysis.description": "Description",
"importAnalysis.slug": "Slug",
"importAnalysis.newEntryWxr": "Nouvelle entrée (WXR)",
"importAnalysis.existingEntry": "Entrée existante",
"importAnalysis.resolution": "Résolution",
"importAnalysis.ignore": "Ignorer",
"importAnalysis.overwrite": "Écraser",
"importAnalysis.importNewSlug": "Importer (nouveau slug)",
"importAnalysis.status": "Statut",
"importAnalysis.title": "Titre",
"importAnalysis.wpStatus": "Statut WP",
"importAnalysis.existingMatch": "Correspondance existante",
"importAnalysis.none": "--",
"importAnalysis.filename": "Nom de fichier",
"importAnalysis.path": "Chemin"
}

View File

@@ -556,5 +556,216 @@
"panel.error.loadPostLinks": "Impossibile caricare i collegamenti del post.",
"panel.error.loadGitLog": "Impossibile caricare il registro Git.",
"panel.direction.from": "da",
"panel.direction.to": "a"
"panel.direction.to": "a",
"settings.editor.description": "Personalizza comportamento e aspetto delleditor.",
"settings.editor.defaultModeLabel": "Modalità predefinita",
"settings.editor.defaultModeDescription": "Scegli la modalità di apertura delleditor.",
"settings.editor.diffViewStyleLabel": "Stile vista diff",
"settings.editor.diffViewStyleDescription": "Definisci come visualizzare le differenze.",
"settings.editor.wrapLongLinesLabel": "A capo automatico",
"settings.editor.wrapLongLinesDescription": "Vai a capo automaticamente per le righe lunghe.",
"settings.editor.wrapLongLinesAria": "Attiva a capo automatico",
"settings.editor.hideUnchangedRegionsLabel": "Nascondi aree non modificate",
"settings.editor.hideUnchangedRegionsDescription": "Comprimi le sezioni senza modifiche nella vista diff.",
"settings.editor.hideUnchangedRegionsAria": "Attiva nascondi aree non modificate",
"settings.content.newCategoryPlaceholder": "Nuova categoria",
"settings.content.addCategory": "Aggiungi categoria",
"settings.content.resetDefaults": "Ripristina predefiniti",
"settings.ai.apiKeyLabel": "Chiave API",
"settings.ai.apiKeyDescription": "Inserisci la tua chiave API per abilitare le funzioni IA.",
"settings.ai.apiKeyConfigured": "Chiave API configurata",
"settings.ai.configured": "Configurato",
"settings.ai.changeApiKey": "Cambia chiave API",
"settings.ai.defaultModelLabel": "Modello predefinito",
"settings.ai.defaultModelDescription": "Seleziona il modello IA usato per default.",
"settings.ai.systemPromptLabel": "Prompt di sistema",
"settings.ai.systemPromptDescription": "Definisci le istruzioni di sistema inviate al modello.",
"settings.ai.systemPromptPlaceholder": "Scrivi qui il tuo prompt di sistema…",
"settings.ai.savePrompt": "Salva prompt",
"settings.ai.resetPrompt": "Reimposta prompt",
"settings.publishing.ftpHostDescription": "Hostname o IP del server FTP.",
"settings.publishing.ftpUsernameDescription": "Nome utente FTP.",
"settings.publishing.ftpPasswordDescription": "Password FTP.",
"settings.publishing.showPassword": "Mostra password",
"settings.publishing.hidePassword": "Nascondi password",
"settings.publishing.sshHostDescription": "Hostname o IP del server SSH.",
"settings.publishing.sshUsernameDescription": "Nome utente SSH.",
"settings.publishing.sshKeyPathDescription": "Percorso della tua chiave privata SSH.",
"settings.data.description": "Gestisci e ricostruisci i dati locali.",
"settings.data.rebuildPostsLabel": "Ricostruisci indice articoli",
"settings.data.rebuildPostsDescription": "Analizza tutti gli articoli e aggiorna lindice dati.",
"settings.data.rebuildPostsAction": "Ricostruisci",
"sidebar.chat.header": "Chat",
"sidebar.chat.newChat": "Nuova chat",
"sidebar.chat.apiKeyNeeded": "Serve una chiave API per usare la chat.",
"sidebar.chat.noConversations": "Nessuna conversazione",
"sidebar.chat.startNew": "Inizia una nuova conversazione",
"sidebar.chat.deleteConversation": "Elimina conversazione",
"sidebar.chat.createFailed": "Creazione conversazione non riuscita: {error}",
"sidebar.chat.deleteFailed": "Eliminazione conversazione non riuscita: {error}",
"sidebar.chat.yesterday": "Ieri",
"sidebar.import.header": "Importazione",
"sidebar.import.newDefinition": "Nuova definizione",
"sidebar.import.none": "Nessuna definizione di importazione",
"sidebar.import.createDefinition": "Crea definizione",
"sidebar.import.deleteDefinition": "Elimina definizione",
"sidebar.import.createFailed": "Creazione definizione non riuscita: {error}",
"sidebar.import.deleteFailed": "Eliminazione definizione non riuscita: {error}",
"editor.error.saveTitle": "Salvataggio non riuscito",
"editor.error.saveMessage": "Impossibile salvare larticolo: {error}",
"editor.error.publishTitle": "Pubblicazione non riuscita",
"editor.error.publishMessage": "Impossibile pubblicare larticolo: {error}",
"editor.error.discardTitle": "Annullamento non riuscito",
"editor.error.deleteTitle": "Eliminazione non riuscita",
"editor.error.operationMessage": "Operazione non riuscita: {error}",
"editor.error.deletePostMessage": "Impossibile eliminare larticolo: {error}",
"editor.error.fetchPostReferencesMessage": "Impossibile recuperare i riferimenti dellarticolo: {error}",
"editor.confirm.discardChanges": "Scartare le modifiche non salvate?",
"editor.confirm.deleteDraft": "Eliminare questa bozza?",
"editor.toast.published": "Articolo pubblicato",
"editor.toast.reverted": "Modifiche annullate",
"editor.toast.draftDeleted": "Bozza eliminata",
"editor.toast.postDeleted": "Articolo eliminato",
"editor.media.notFound": "Media non trovato",
"editor.media.error.analyzeImage": "Impossibile analizzare limmagine: {error}",
"editor.media.error.updateTitle": "Aggiornamento non riuscito",
"editor.media.error.updateMessage": "Impossibile aggiornare il media: {error}",
"editor.media.error.replaceTitle": "Sostituzione non riuscita",
"editor.media.error.replaceMessage": "Impossibile sostituire il file media: {error}",
"editor.media.error.deleteMessage": "Impossibile eliminare il media: {error}",
"editor.media.error.fetchReferencesMessage": "Impossibile recuperare i riferimenti del media: {error}",
"editor.media.toast.aiApplied": "Suggerimenti IA applicati",
"editor.media.toast.linkedToPost": "Media collegato allarticolo",
"editor.media.toast.linkFailed": "Collegamento del media non riuscito: {error}",
"editor.media.toast.unlinkedFromPost": "Media scollegato dallarticolo",
"editor.media.toast.unlinkFailed": "Scollegamento del media non riuscito: {error}",
"editor.media.toast.updated": "Media aggiornato",
"editor.media.toast.fileReplaced": "File media sostituito",
"editor.media.toast.deleted": "Media eliminato",
"editor.media.quickActions.title": "Azioni rapide",
"editor.media.quickActions.analyzing": "🔎 Analisi in corso…",
"editor.media.quickActions.button": "✨ Analizza con IA",
"editor.media.quickActions.aiTitle": "Titolo suggerito dallIA",
"editor.media.quickActions.aiDescription": "Genera automaticamente titolo, testo alternativo e didascalia.",
"editor.media.replaceFile": "Sostituisci file",
"editor.media.field.fileName": "Nome file",
"editor.media.field.type": "Tipo",
"editor.media.field.size": "Dimensione",
"editor.media.field.dimensions": "Dimensioni",
"editor.media.field.title": "Titolo",
"editor.media.field.altText": "Testo alternativo",
"editor.media.field.caption": "Didascalia",
"editor.media.field.tags": "Tag",
"editor.media.field.author": "Autore",
"editor.media.placeholder.title": "Inserisci un titolo",
"editor.media.placeholder.altText": "Descrivi limmagine per laccessibilità",
"editor.media.placeholder.caption": "Aggiungi una didascalia",
"editor.media.placeholder.tags": "Aggiungi tag",
"editor.media.placeholder.author": "Nome autore",
"editor.media.linkedPosts": "Articoli collegati",
"editor.media.linkToPostTitle": "Collega a un articolo",
"editor.media.linkAction": "Collega",
"editor.media.searchPosts": "Cerca articoli",
"editor.media.noMatchingPosts": "Nessun articolo corrisponde a “{query}”",
"editor.media.noPostsToLink": "Nessun articolo disponibile da collegare",
"editor.media.morePosts": "{count} altri articoli",
"editor.media.notLinked": "Non collegato",
"editor.media.openPost": "Apri articolo",
"editor.media.unlinkFromPost": "Scollega dallarticolo",
"postSearch.placeholder": "Cerca articoli…",
"postSearch.searching": "Ricerca in corso…",
"postSearch.typeMore": "Digita più caratteri per cercare",
"postSearch.noResults": "Nessun risultato per “{query}”",
"postSearch.hint": "Cerca per titolo, slug o contenuto",
"statusBar.posts": "Articoli",
"statusBar.media": "Media",
"statusBar.theme": "Tema: {theme}",
"statusBar.ui": "UI",
"statusBar.uiLanguage": "Lingua interfaccia",
"windowTitleBar.toggleSidebar": "Mostra/Nascondi barra laterale",
"windowTitleBar.hideSidebar": "Nascondi barra laterale",
"windowTitleBar.showSidebar": "Mostra barra laterale",
"windowTitleBar.togglePanel": "Mostra/Nascondi pannello",
"windowTitleBar.hidePanel": "Nascondi pannello",
"windowTitleBar.showPanel": "Mostra pannello",
"tagInput.alreadyAdded": "Il tag “{tag}” è già stato aggiunto",
"tagInput.remove": "Rimuovi",
"tagInput.createdTag": "Tag “{tag}” creato",
"tagInput.createdCategory": "Categoria “{name}” creata",
"tagInput.createTag": "Crea tag “{tag}”",
"tagInput.createCategory": "Crea categoria “{name}”",
"importAnalysis.loadingDefinition": "Caricamento definizione di importazione…",
"importAnalysis.namePlaceholder": "Nome definizione di importazione",
"importAnalysis.headerDescription": "Analizza un file WXR prima dellimportazione.",
"importAnalysis.uploadsFolder": "Cartella uploads",
"importAnalysis.noFolderSelected": "Nessuna cartella selezionata",
"importAnalysis.wxrFile": "File WXR",
"importAnalysis.selectFileToAnalyze": "Seleziona un file da analizzare",
"importAnalysis.analyzing": "Analisi…",
"importAnalysis.selectAndAnalyze": "Seleziona e analizza",
"importAnalysis.analyzingWxr": "Analisi del file WXR…",
"importAnalysis.emptyState": "Seleziona un file WXR e avvia lanalisi.",
"importAnalysis.importing": "Importazione in corso…",
"importAnalysis.importComplete": "Importazione completata: {count}",
"importAnalysis.importFailed": "Importazione non riuscita: {error}",
"importAnalysis.untitledImport": "Importazione senza titolo",
"importAnalysis.executionStarting": "Avvio...",
"importAnalysis.unknownError": "Errore sconosciuto",
"importAnalysis.readyToImport": "Pronto per importare:",
"importAnalysis.tagsCategories": "tag/categorie",
"importAnalysis.posts": "articoli",
"importAnalysis.media": "media",
"importAnalysis.pages": "pagine",
"importAnalysis.nothingToImport": "Niente da importare",
"importAnalysis.importItems": "Importa {count} elementi",
"importAnalysis.postSlugConflicts": "Conflitti slug articoli",
"importAnalysis.pageSlugConflicts": "Conflitti slug pagine",
"importAnalysis.postsWithCount": "Articoli ({count})",
"importAnalysis.otherWithCount": "Altro ({count})",
"importAnalysis.pagesWithCount": "Pagine ({count})",
"importAnalysis.mediaWithCount": "Media ({count})",
"importAnalysis.site": "Sito",
"importAnalysis.untitled": "Senza titolo",
"importAnalysis.url": "URL",
"importAnalysis.language": "Lingua",
"importAnalysis.file": "File",
"importAnalysis.notAvailable": "N/D",
"importAnalysis.new": "nuovo",
"importAnalysis.update": "aggiornamento",
"importAnalysis.conflict": "conflitto",
"importAnalysis.duplicate": "duplicato",
"importAnalysis.missing": "mancante",
"importAnalysis.categories": "Categorie",
"importAnalysis.existing": "esistente",
"importAnalysis.mapped": "mappato",
"importAnalysis.tags": "Tag",
"importAnalysis.dateDistribution": "Distribuzione per data",
"importAnalysis.postsPages": "Articoli/Pagine",
"importAnalysis.total": "totale",
"importAnalysis.wordpressId": "ID WordPress",
"importAnalysis.type": "Tipo",
"importAnalysis.author": "Autore",
"importAnalysis.unknown": "Sconosciuto",
"importAnalysis.published": "Pubblicato",
"importAnalysis.excerpt": "Estratto",
"importAnalysis.content": "Contenuto",
"importAnalysis.loading": "Caricamento...",
"importAnalysis.mimeType": "Tipo MIME",
"importAnalysis.uploaded": "Caricato",
"importAnalysis.parentPostId": "ID articolo padre",
"importAnalysis.description": "Descrizione",
"importAnalysis.slug": "Slug",
"importAnalysis.newEntryWxr": "Nuova voce (WXR)",
"importAnalysis.existingEntry": "Voce esistente",
"importAnalysis.resolution": "Risoluzione",
"importAnalysis.ignore": "Ignora",
"importAnalysis.overwrite": "Sovrascrivi",
"importAnalysis.importNewSlug": "Importa (nuovo slug)",
"importAnalysis.status": "Stato",
"importAnalysis.title": "Titolo",
"importAnalysis.wpStatus": "Stato WP",
"importAnalysis.existingMatch": "Corrispondenza esistente",
"importAnalysis.none": "--",
"importAnalysis.filename": "Nome file",
"importAnalysis.path": "Percorso"
}