chore: more i18n going on
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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' : ''}`}>▶</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' : ''}`}>▶</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' : ''}`}>▶</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' : ''}`}>▶</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' : ''}`}>▶</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' : ''}`}>▶</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[];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}`}
|
||||
|
||||
Reference in New Issue
Block a user