feat: i18n support with first translations
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './AISuggestionsModal.css';
|
||||
|
||||
export interface AISuggestions {
|
||||
@@ -21,9 +22,9 @@ interface SuggestionFieldConfig {
|
||||
}
|
||||
|
||||
const SUGGESTION_FIELDS: SuggestionFieldConfig[] = [
|
||||
{ key: 'title', label: 'Title' },
|
||||
{ key: 'alt', label: 'Alt Text' },
|
||||
{ key: 'caption', label: 'Caption' },
|
||||
{ key: 'title', label: 'aiSuggestions.titleField' },
|
||||
{ key: 'alt', label: 'aiSuggestions.altField' },
|
||||
{ key: 'caption', label: 'aiSuggestions.captionField' },
|
||||
];
|
||||
|
||||
interface AISuggestionsModalProps {
|
||||
@@ -45,6 +46,7 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
||||
onConfirm,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t: tr } = useI18n();
|
||||
// Checkbox state - initialized based on whether current values are empty
|
||||
const [useTitle, setUseTitle] = useState(false);
|
||||
const [useAlt, setUseAlt] = useState(false);
|
||||
@@ -107,15 +109,15 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
||||
<div className="ai-suggestion-label">
|
||||
{field.label}
|
||||
{currentValue && (
|
||||
<span className="ai-suggestion-has-value" title="This field already has a value">
|
||||
(has existing value)
|
||||
<span className="ai-suggestion-has-value" title={tr('aiSuggestions.hasExisting')}>
|
||||
{tr('aiSuggestions.hasExisting')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ai-suggestion-value">{suggestedValue}</div>
|
||||
{currentValue && (
|
||||
<div className="ai-suggestion-current">
|
||||
Current: <em>{currentValue}</em>
|
||||
{tr('aiSuggestions.current')}: <em>{currentValue}</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -127,9 +129,9 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
||||
<div className="ai-suggestions-modal-backdrop" onClick={handleBackdropClick}>
|
||||
<div className="ai-suggestions-modal">
|
||||
<div className="ai-suggestions-modal-header">
|
||||
<h2>AI Image Analysis</h2>
|
||||
<h2>{tr('aiSuggestions.title')}</h2>
|
||||
{!isLoading && (
|
||||
<button className="ai-suggestions-modal-close" onClick={onClose} title="Close">
|
||||
<button className="ai-suggestions-modal-close" onClick={onClose} title={tr('aiSuggestions.close')}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
@@ -139,7 +141,7 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
||||
{isLoading && (
|
||||
<div className="ai-suggestions-loading">
|
||||
<div className="ai-suggestions-spinner"></div>
|
||||
<p>Analyzing image...</p>
|
||||
<p>{tr('aiSuggestions.analyzing')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -153,15 +155,15 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
||||
{!isLoading && !error && hasAnySuggestion && (
|
||||
<div className="ai-suggestions-list">
|
||||
<p className="ai-suggestions-intro">
|
||||
Select which AI-generated values to apply. Existing values are preserved by default.
|
||||
{tr('aiSuggestions.intro')}
|
||||
</p>
|
||||
{SUGGESTION_FIELDS.map(renderSuggestionField)}
|
||||
{SUGGESTION_FIELDS.map((field) => renderSuggestionField({ ...field, label: tr(field.label) }))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && !hasAnySuggestion && suggestions && (
|
||||
<div className="ai-suggestions-empty">
|
||||
No suggestions were generated for this image.
|
||||
{tr('aiSuggestions.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -169,12 +171,12 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
||||
<div className="ai-suggestions-modal-footer">
|
||||
{isLoading ? (
|
||||
<button className="button-cancel" disabled>
|
||||
Please wait...
|
||||
{tr('aiSuggestions.wait')}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button className="button-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
{tr('common.cancel')}
|
||||
</button>
|
||||
{hasAnySuggestion && (
|
||||
<button
|
||||
@@ -182,7 +184,7 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
||||
onClick={handleConfirm}
|
||||
disabled={!hasAnySelected}
|
||||
>
|
||||
Apply Selected
|
||||
{tr('aiSuggestions.applySelected')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './ActivityBar.css';
|
||||
|
||||
// Simple SVG icons
|
||||
@@ -56,6 +57,7 @@ const GitIcon = () => (
|
||||
);
|
||||
|
||||
export const ActivityBar: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { activeView, setActiveView, sidebarVisible, toggleSidebar, openTab, tabs, activeTabId } = useAppStore();
|
||||
|
||||
// Check if settings tab is currently active
|
||||
@@ -127,42 +129,42 @@ export const ActivityBar: React.FC = () => {
|
||||
<button
|
||||
className={`activity-bar-item ${activeView === 'posts' && sidebarVisible ? 'active' : ''}`}
|
||||
onClick={() => handleViewClick('posts')}
|
||||
title="Posts (click again to toggle sidebar)"
|
||||
title={`${t('activity.posts')} ${t('activity.toggleHint')}`}
|
||||
>
|
||||
<PostsIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${activeView === 'pages' && sidebarVisible ? 'active' : ''}`}
|
||||
onClick={() => handleViewClick('pages')}
|
||||
title="Pages (click again to toggle sidebar)"
|
||||
title={`${t('activity.pages')} ${t('activity.toggleHint')}`}
|
||||
>
|
||||
<PagesIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${activeView === 'media' && sidebarVisible ? 'active' : ''}`}
|
||||
onClick={() => handleViewClick('media')}
|
||||
title="Media (click again to toggle sidebar)"
|
||||
title={`${t('activity.media')} ${t('activity.toggleHint')}`}
|
||||
>
|
||||
<MediaIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isTagsTabActive ? 'active' : ''}`}
|
||||
onClick={handleTagsClick}
|
||||
title="Tags"
|
||||
title={t('activity.tags')}
|
||||
>
|
||||
<TagsIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isChatActive ? 'active' : ''}`}
|
||||
onClick={() => handleViewClick('chat')}
|
||||
title="AI Assistant (click again to toggle sidebar)"
|
||||
title={`${t('activity.aiAssistant')} ${t('activity.toggleHint')}`}
|
||||
>
|
||||
<ChatIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isImportActive ? 'active' : ''}`}
|
||||
onClick={handleImportClick}
|
||||
title="Import (click again to toggle sidebar)"
|
||||
title={`${t('activity.import')} ${t('activity.toggleHint')}`}
|
||||
>
|
||||
<ImportIcon />
|
||||
</button>
|
||||
@@ -172,14 +174,14 @@ export const ActivityBar: React.FC = () => {
|
||||
<button
|
||||
className={`activity-bar-item ${isGitActive ? 'active' : ''}`}
|
||||
onClick={() => handleViewClick('git')}
|
||||
title="Source Control (click again to toggle sidebar)"
|
||||
title={`${t('activity.sourceControl')} ${t('activity.toggleHint')}`}
|
||||
>
|
||||
<GitIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isSettingsActive ? 'active' : ''}`}
|
||||
onClick={handleSettingsClick}
|
||||
title="Settings (click again to toggle sidebar)"
|
||||
title={`${t('common.settings')} ${t('activity.toggleHint')}`}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Markdown from 'marked-react';
|
||||
import type { ChatMessage, ChatConversation, ChatModel } from '../../types/electron';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './ChatPanel.css';
|
||||
|
||||
interface ChatPanelProps {
|
||||
@@ -8,6 +9,7 @@ interface ChatPanelProps {
|
||||
}
|
||||
|
||||
export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
const { t: tr } = useI18n();
|
||||
const [conversation, setConversation] = useState<ChatConversation | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
@@ -126,10 +128,10 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
setApiKeyInput('');
|
||||
loadData();
|
||||
} else {
|
||||
setApiKeyError('Invalid API key. Please check and try again.');
|
||||
setApiKeyError(tr('chat.apiKeyInvalid'));
|
||||
}
|
||||
} catch {
|
||||
setApiKeyError('Failed to validate API key.');
|
||||
setApiKeyError(tr('chat.apiKeyValidationFailed'));
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
@@ -184,7 +186,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
id: `error-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: `Error: ${result.error || 'Failed to get a response. Please try again.'}`,
|
||||
content: tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
@@ -195,7 +197,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
id: `empty-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: 'The model returned an empty response. Try a different model or rephrase your question.',
|
||||
content: tr('chat.errorEmptyResponse'),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, noContentMessage]);
|
||||
@@ -206,7 +208,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
id: `error-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: 'Sorry, an error occurred while processing your message.',
|
||||
content: tr('chat.errorGeneric'),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
@@ -241,7 +243,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
id: `partial-${Date.now()}`,
|
||||
conversationId,
|
||||
role: 'assistant',
|
||||
content: partialContent + '\n\n*(cancelled)*',
|
||||
content: `${partialContent}\n\n*(${tr('chat.cancelledSuffix')})*`,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, partialMessage]);
|
||||
@@ -323,7 +325,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-message-header">
|
||||
<span className="chat-message-role">
|
||||
{msg.role === 'user' ? 'You' : 'Assistant'}
|
||||
{msg.role === 'user' ? tr('chat.role.you') : tr('chat.role.assistant')}
|
||||
</span>
|
||||
</div>
|
||||
{storedToolCalls.length > 0 && (
|
||||
@@ -361,13 +363,13 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel-header">
|
||||
<div className="chat-panel-title">AI Chat Setup</div>
|
||||
<div className="chat-panel-title">{tr('chat.setupTitle')}</div>
|
||||
</div>
|
||||
<div className="chat-messages">
|
||||
<div className="chat-welcome">
|
||||
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
|
||||
<h2>OpenCode Zen API Key Required</h2>
|
||||
<p>Enter your OpenCode API key to enable AI chat.</p>
|
||||
<h2>{tr('chat.apiKeyRequiredTitle')}</h2>
|
||||
<p>{tr('chat.apiKeyRequiredDescription')}</p>
|
||||
<div className="api-key-form">
|
||||
<input
|
||||
type="password"
|
||||
@@ -375,7 +377,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()}
|
||||
placeholder="Enter your API key..."
|
||||
placeholder={tr('chat.apiKeyPlaceholder')}
|
||||
disabled={isValidating}
|
||||
/>
|
||||
<button
|
||||
@@ -383,7 +385,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
onClick={handleApiKeySubmit}
|
||||
disabled={!apiKeyInput.trim() || isValidating}
|
||||
>
|
||||
{isValidating ? 'Validating...' : 'Save Key'}
|
||||
{isValidating ? tr('chat.apiKeyValidating') : tr('chat.apiKeySave')}
|
||||
</button>
|
||||
{apiKeyError && <div className="api-key-error">{apiKeyError}</div>}
|
||||
</div>
|
||||
@@ -397,7 +399,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel-header">
|
||||
<div className="chat-panel-title">
|
||||
{conversation?.title || 'New Chat'}
|
||||
{conversation?.title || tr('chat.newChat')}
|
||||
</div>
|
||||
<div className="chat-panel-model">
|
||||
<button
|
||||
@@ -427,14 +429,14 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<div className="chat-welcome">
|
||||
<div className="chat-welcome-icon">{'\u{1F916}'}</div>
|
||||
<h2>Welcome to the AI Assistant</h2>
|
||||
<p>I can help you manage your posts and media. Try asking me to:</p>
|
||||
<h2>{tr('chat.welcomeTitle')}</h2>
|
||||
<p>{tr('chat.welcomeDescription')}</p>
|
||||
<ul>
|
||||
<li>Search for posts about a specific topic</li>
|
||||
<li>Get details about a specific post</li>
|
||||
<li>List all tags or categories in your blog</li>
|
||||
<li>Update metadata for posts or media</li>
|
||||
<li>List all images in your media library</li>
|
||||
<li>{tr('chat.welcomeTipSearch')}</li>
|
||||
<li>{tr('chat.welcomeTipDetails')}</li>
|
||||
<li>{tr('chat.welcomeTipTags')}</li>
|
||||
<li>{tr('chat.welcomeTipMetadata')}</li>
|
||||
<li>{tr('chat.welcomeTipImages')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -446,7 +448,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||
<div className="chat-message-content">
|
||||
<div className="chat-message-header">
|
||||
<span className="chat-message-role">Assistant</span>
|
||||
<span className="chat-message-role">{tr('chat.role.assistant')}</span>
|
||||
<span className="streaming-indicator">{'\u25CF'}</span>
|
||||
</div>
|
||||
{renderToolMarkers(toolEvents)}
|
||||
@@ -476,7 +478,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
<div className="chat-input-container">
|
||||
{isStreaming && (
|
||||
<button className="chat-abort-button" onClick={handleAbort}>
|
||||
{'\u25FC'} Stop
|
||||
{'\u25FC'} {tr('chat.stop')}
|
||||
</button>
|
||||
)}
|
||||
<div className="chat-input-wrapper">
|
||||
@@ -491,7 +493,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
placeholder={tr('chat.inputPlaceholder')}
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './ConfirmDeleteModal.css';
|
||||
|
||||
export interface DeleteReference {
|
||||
@@ -20,6 +21,7 @@ interface ConfirmDeleteModalProps {
|
||||
}
|
||||
|
||||
export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details, onClose }) => {
|
||||
const { t: tr } = useI18n();
|
||||
if (!details) return null;
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
@@ -39,14 +41,14 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
|
||||
<div className="confirm-delete-modal-backdrop" onClick={handleBackdropClick}>
|
||||
<div className="confirm-delete-modal">
|
||||
<div className="confirm-delete-modal-header">
|
||||
<h2>Confirm Deletion</h2>
|
||||
<button className="confirm-delete-modal-close" onClick={onClose} title="Close">
|
||||
<h2>{tr('confirmDelete.title')}</h2>
|
||||
<button className="confirm-delete-modal-close" onClick={onClose} title={tr('aiSuggestions.close')}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="confirm-delete-modal-body">
|
||||
<div className="confirm-delete-message">
|
||||
Are you sure you want to delete {details.itemType === 'post' ? 'the post' : 'the media file'}{' '}
|
||||
{details.itemType === 'post' ? tr('confirmDelete.promptPost') : tr('confirmDelete.promptMedia')}{' '}
|
||||
<strong>{details.itemTitle}</strong>?
|
||||
</div>
|
||||
|
||||
@@ -54,7 +56,7 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
|
||||
<div className="confirm-delete-warning">
|
||||
<div className="warning-icon">⚠️</div>
|
||||
<div className="warning-content">
|
||||
<strong>Warning:</strong> This {details.itemType} is referenced by the following items:
|
||||
<strong>{tr('confirmDelete.warning')}</strong> {tr('confirmDelete.referencedBy', { itemType: tr(`confirmDelete.itemType.${details.itemType}`) })}
|
||||
<ul className="reference-list">
|
||||
{details.references.map((ref) => (
|
||||
<li key={ref.id}>
|
||||
@@ -66,7 +68,7 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
|
||||
))}
|
||||
</ul>
|
||||
<p className="warning-note">
|
||||
Deleting this {details.itemType} will remove all these references.
|
||||
{tr('confirmDelete.note', { itemType: tr(`confirmDelete.itemType.${details.itemType}`) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,10 +76,10 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
|
||||
</div>
|
||||
<div className="confirm-delete-modal-footer">
|
||||
<button className="button-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
{tr('confirmDelete.cancel')}
|
||||
</button>
|
||||
<button className="button-delete" onClick={handleConfirm}>
|
||||
Delete {details.itemType === 'post' ? 'Post' : 'Media'}
|
||||
{details.itemType === 'post' ? tr('confirmDelete.deletePost') : tr('confirmDelete.deleteMedia')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { showToast } from '../Toast';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './CredentialsPanel.css';
|
||||
|
||||
interface Credentials {
|
||||
@@ -12,6 +13,7 @@ interface Credentials {
|
||||
}
|
||||
|
||||
export const CredentialsPanel: React.FC = () => {
|
||||
const { t: tr } = useI18n();
|
||||
const [credentials, setCredentials] = useState<Credentials>({
|
||||
ftpHost: '',
|
||||
ftpUser: '',
|
||||
@@ -32,7 +34,7 @@ export const CredentialsPanel: React.FC = () => {
|
||||
setCredentials(JSON.parse(savedCreds));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load credentials:', error);
|
||||
console.error(tr('credentials.error.load'), error);
|
||||
}
|
||||
};
|
||||
loadCredentials();
|
||||
@@ -43,10 +45,10 @@ export const CredentialsPanel: React.FC = () => {
|
||||
// Save to localStorage (in production, use secure storage)
|
||||
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
|
||||
|
||||
showToast.success('Credentials saved');
|
||||
showToast.success(tr('credentials.toast.saved'));
|
||||
} catch (error) {
|
||||
console.error('Failed to save credentials:', error);
|
||||
showToast.error('Failed to save credentials');
|
||||
console.error(tr('credentials.error.save'), error);
|
||||
showToast.error(tr('credentials.toast.saveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,14 +70,14 @@ export const CredentialsPanel: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleTestConnection = async (type: 'ftp' | 'ssh') => {
|
||||
showToast.loading(`Testing ${type.toUpperCase()} connection...`);
|
||||
showToast.loading(tr('credentials.toast.testing', { type: type.toUpperCase() }));
|
||||
|
||||
// Simulate connection test
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// In a real implementation, this would test the actual connection
|
||||
showToast.dismiss();
|
||||
showToast.error('Connection failed - check credentials');
|
||||
showToast.error(tr('credentials.toast.connectionFailed'));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -85,13 +87,13 @@ export const CredentialsPanel: React.FC = () => {
|
||||
className={activeTab === 'ftp' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('ftp')}
|
||||
>
|
||||
FTP
|
||||
{tr('credentials.tab.ftp')}
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'ssh' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('ssh')}
|
||||
>
|
||||
SSH
|
||||
{tr('credentials.tab.ssh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -99,49 +101,49 @@ export const CredentialsPanel: React.FC = () => {
|
||||
{activeTab === 'ftp' && (
|
||||
<div className="credentials-form">
|
||||
<div className="credentials-header">
|
||||
<h4>FTP Publishing</h4>
|
||||
<h4>{tr('credentials.ftp.title')}</h4>
|
||||
<p className="text-muted">
|
||||
Configure FTP for publishing your blog to a web server.
|
||||
{tr('credentials.ftp.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>Host</label>
|
||||
<label>{tr('credentials.field.host')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ftp.example.com"
|
||||
placeholder={tr('credentials.ftp.placeholder.host')}
|
||||
value={credentials.ftpHost}
|
||||
onChange={(e) => setCredentials({ ...credentials, ftpHost: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>Username</label>
|
||||
<label>{tr('credentials.field.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ftp-user"
|
||||
placeholder={tr('credentials.ftp.placeholder.username')}
|
||||
value={credentials.ftpUser}
|
||||
onChange={(e) => setCredentials({ ...credentials, ftpUser: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>Password</label>
|
||||
<label>{tr('credentials.field.password')}</label>
|
||||
<input
|
||||
type={showTokens ? 'text' : 'password'}
|
||||
placeholder="Password"
|
||||
placeholder={tr('credentials.ftp.placeholder.password')}
|
||||
value={credentials.ftpPassword}
|
||||
onChange={(e) => setCredentials({ ...credentials, ftpPassword: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-actions">
|
||||
<button onClick={handleSave}>Save</button>
|
||||
<button onClick={handleSave}>{tr('common.save')}</button>
|
||||
<button className="secondary" onClick={() => handleTestConnection('ftp')}>
|
||||
Test Connection
|
||||
{tr('credentials.action.testConnection')}
|
||||
</button>
|
||||
<button className="secondary danger" onClick={() => handleClear('ftp')}>
|
||||
Clear
|
||||
{tr('common.clear')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,49 +152,49 @@ export const CredentialsPanel: React.FC = () => {
|
||||
{activeTab === 'ssh' && (
|
||||
<div className="credentials-form">
|
||||
<div className="credentials-header">
|
||||
<h4>SSH Publishing</h4>
|
||||
<h4>{tr('credentials.ssh.title')}</h4>
|
||||
<p className="text-muted">
|
||||
Configure SSH for secure publishing to your server.
|
||||
{tr('credentials.ssh.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>Host</label>
|
||||
<label>{tr('credentials.field.host')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="server.example.com"
|
||||
placeholder={tr('credentials.ssh.placeholder.host')}
|
||||
value={credentials.sshHost}
|
||||
onChange={(e) => setCredentials({ ...credentials, sshHost: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>Username</label>
|
||||
<label>{tr('credentials.field.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ssh-user"
|
||||
placeholder={tr('credentials.ssh.placeholder.username')}
|
||||
value={credentials.sshUser}
|
||||
onChange={(e) => setCredentials({ ...credentials, sshUser: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-field">
|
||||
<label>SSH Key Path</label>
|
||||
<label>{tr('credentials.field.sshKeyPath')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="~/.ssh/id_rsa"
|
||||
placeholder={tr('credentials.ssh.placeholder.keyPath')}
|
||||
value={credentials.sshKeyPath}
|
||||
onChange={(e) => setCredentials({ ...credentials, sshKeyPath: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="credentials-actions">
|
||||
<button onClick={handleSave}>Save</button>
|
||||
<button onClick={handleSave}>{tr('common.save')}</button>
|
||||
<button className="secondary" onClick={() => handleTestConnection('ssh')}>
|
||||
Test Connection
|
||||
{tr('credentials.action.testConnection')}
|
||||
</button>
|
||||
<button className="secondary danger" onClick={() => handleClear('ssh')}>
|
||||
Clear
|
||||
{tr('common.clear')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,12 @@ import React, { useEffect } from 'react';
|
||||
import Markdown from 'marked-react';
|
||||
import documentationContent from '../../../../DOCUMENTATION.md?raw';
|
||||
import { useAppStore } from '../../store';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme';
|
||||
import './DocumentationView.css';
|
||||
|
||||
export const DocumentationView: React.FC = () => {
|
||||
const { t: tr } = useI18n();
|
||||
const { picoTheme } = useAppStore();
|
||||
const resolvedTheme = getRendererPicoTheme(picoTheme);
|
||||
|
||||
@@ -18,8 +20,8 @@ export const DocumentationView: React.FC = () => {
|
||||
return (
|
||||
<div className="documentation-view">
|
||||
<div className="documentation-header">
|
||||
<h1>Documentation</h1>
|
||||
<p>User guide for this installed bDS version.</p>
|
||||
<h1>{tr('docs.title')}</h1>
|
||||
<p>{tr('docs.subtitle')}</p>
|
||||
</div>
|
||||
<main className="documentation-scroll">
|
||||
<div className="documentation-content markdown-body pico" data-theme="auto" data-pico-theme={resolvedTheme}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './ErrorModal.css';
|
||||
|
||||
export interface ErrorDetails {
|
||||
@@ -13,16 +14,17 @@ interface ErrorModalProps {
|
||||
}
|
||||
|
||||
export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
|
||||
const { t: tr } = useI18n();
|
||||
if (!error) return null;
|
||||
|
||||
const handleCopyStack = useCallback(async () => {
|
||||
const textToCopy = `${error.title || 'Error'}\n${error.message}\n\nStack Trace:\n${error.stack || 'No stack trace available'}`;
|
||||
const textToCopy = `${error.title || tr('errorModal.error')}\n${error.message}\n\n${tr('errorModal.stackTrace')}:\n${error.stack || tr('errorModal.noStack')}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
}
|
||||
}, [error]);
|
||||
}, [error, tr]);
|
||||
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
@@ -34,8 +36,8 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
|
||||
<div className="error-modal-backdrop" onClick={handleBackdropClick}>
|
||||
<div className="error-modal">
|
||||
<div className="error-modal-header">
|
||||
<h2>{error.title || 'Error'}</h2>
|
||||
<button className="error-modal-close" onClick={onClose} title="Close">
|
||||
<h2>{error.title || tr('errorModal.error')}</h2>
|
||||
<button className="error-modal-close" onClick={onClose} title={tr('aiSuggestions.close')}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@@ -44,9 +46,9 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
|
||||
{error.stack && (
|
||||
<div className="error-stack-section">
|
||||
<div className="error-stack-header">
|
||||
<span>Stack Trace</span>
|
||||
<button className="copy-button" onClick={handleCopyStack} title="Copy to clipboard">
|
||||
📋 Copy
|
||||
<span>{tr('errorModal.stackTrace')}</span>
|
||||
<button className="copy-button" onClick={handleCopyStack} title={tr('errorModal.copyClipboard')}>
|
||||
📋 {tr('errorModal.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="error-stack">{error.stack}</pre>
|
||||
@@ -54,7 +56,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="error-modal-footer">
|
||||
<button onClick={onClose}>Close</button>
|
||||
<button onClick={onClose}>{tr('aiSuggestions.close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { DiffEditor } from '@monaco-editor/react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './GitDiffView.css';
|
||||
|
||||
interface CommitFileDiff {
|
||||
@@ -47,6 +48,7 @@ function toModelPath(filePath: string, side: 'original' | 'modified', scope: str
|
||||
}
|
||||
|
||||
export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
||||
const { t: tr } = useI18n();
|
||||
const { activeProject, gitDiffPreferences } = useAppStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -94,7 +96,7 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
||||
|
||||
try {
|
||||
if (!activeProject) {
|
||||
setError('No active project selected.');
|
||||
setError(tr('gitDiff.noProject'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,7 +105,7 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
||||
: await window.electronAPI.app.getDefaultProjectPath(activeProject.id);
|
||||
|
||||
if (!projectPath) {
|
||||
setError('Unable to resolve project path.');
|
||||
setError(tr('gitDiff.noProjectPath'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -129,20 +131,23 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
||||
setModified(diff.modified || '');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to load diff.');
|
||||
setError(tr('gitDiff.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadDiff();
|
||||
}, [activeProject, filePath, isCommitDiff, commitHash]);
|
||||
}, [activeProject, filePath, isCommitDiff, commitHash, tr]);
|
||||
|
||||
const headerTarget = isCommitDiff ? `Commit ${commitHash}` : filePath;
|
||||
const headerLabel = tr('gitDiff.header', { target: headerTarget });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="git-diff-view">
|
||||
<div className="git-diff-header">Diff: {isCommitDiff ? `Commit ${commitHash}` : filePath}</div>
|
||||
<div className="git-diff-message">Loading diff...</div>
|
||||
<div className="git-diff-header">{headerLabel}</div>
|
||||
<div className="git-diff-message">{tr('gitDiff.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -150,7 +155,7 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="git-diff-view">
|
||||
<div className="git-diff-header">Diff: {isCommitDiff ? `Commit ${commitHash}` : filePath}</div>
|
||||
<div className="git-diff-header">{headerLabel}</div>
|
||||
<div className="git-diff-error">{error}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -158,27 +163,27 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
||||
|
||||
return (
|
||||
<div className="git-diff-view">
|
||||
<div className="git-diff-header">Diff: {isCommitDiff ? `Commit ${commitHash}` : filePath}</div>
|
||||
<div className="git-diff-header">{headerLabel}</div>
|
||||
{isCommitDiff && commitFiles.length > 0 && (
|
||||
<div className="git-diff-commit-nav">
|
||||
<label htmlFor="git-diff-commit-files" className="git-diff-commit-label">
|
||||
Changed files
|
||||
{tr('gitDiff.changedFiles')}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="git-diff-commit-button"
|
||||
onClick={selectPreviousCommitFile}
|
||||
disabled={!canSelectPreviousFile}
|
||||
aria-label="Previous file"
|
||||
aria-label={tr('gitDiff.previousFile')}
|
||||
>
|
||||
Previous
|
||||
{tr('gitDiff.previousFile')}
|
||||
</button>
|
||||
<select
|
||||
id="git-diff-commit-files"
|
||||
className="git-diff-commit-select"
|
||||
value={selectedCommitFilePath}
|
||||
onChange={(event) => setSelectedCommitFilePath(event.target.value)}
|
||||
aria-label="Changed files"
|
||||
aria-label={tr('gitDiff.changedFiles')}
|
||||
>
|
||||
{commitFiles.map((entry) => (
|
||||
<option key={entry.filePath} value={entry.filePath}>
|
||||
@@ -191,9 +196,9 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
||||
className="git-diff-commit-button"
|
||||
onClick={selectNextCommitFile}
|
||||
disabled={!canSelectNextFile}
|
||||
aria-label="Next file"
|
||||
aria-label={tr('gitDiff.nextFile')}
|
||||
>
|
||||
Next
|
||||
{tr('gitDiff.nextFile')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { useI18n } from '../../i18n';
|
||||
import type { GitInitProgress, GitHistoryEntry, GitRemoteStateDto } from '../../../main/shared/electronApi';
|
||||
import './GitSidebar.css';
|
||||
import '../Sidebar/Sidebar.css';
|
||||
@@ -27,6 +28,7 @@ const mergeStatusFilesIncremental = (
|
||||
};
|
||||
|
||||
export const GitSidebar: React.FC = () => {
|
||||
const { t: tr } = useI18n();
|
||||
const { activeProject, openTab, tabs, closeTab } = useAppStore();
|
||||
const [projectPath, setProjectPath] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -98,7 +100,7 @@ export const GitSidebar: React.FC = () => {
|
||||
if (fetchFirst) {
|
||||
const fetchResult = await window.electronAPI.git.fetch(targetProjectPath);
|
||||
if (!fetchResult.success) {
|
||||
const message = fetchResult.error || 'Failed to fetch remote updates.';
|
||||
const message = fetchResult.error || tr('gitSidebar.error.fetchRemoteUpdates');
|
||||
setRemoteStateError(message);
|
||||
if (!background) {
|
||||
setError(message);
|
||||
@@ -111,7 +113,7 @@ export const GitSidebar: React.FC = () => {
|
||||
setRemoteState(nextRemoteState);
|
||||
setRemoteStateError(null);
|
||||
} catch {
|
||||
const message = 'Unable to refresh remote tracking state.';
|
||||
const message = tr('gitSidebar.error.refreshRemoteState');
|
||||
setRemoteStateError(message);
|
||||
if (!background) {
|
||||
setError(message);
|
||||
@@ -120,7 +122,7 @@ export const GitSidebar: React.FC = () => {
|
||||
remoteRefreshInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[tr],
|
||||
);
|
||||
|
||||
const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`;
|
||||
@@ -128,28 +130,28 @@ export const GitSidebar: React.FC = () => {
|
||||
|
||||
const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'prune-lfs' | 'commit'): string => {
|
||||
if (action === 'push') {
|
||||
return 'Pushing commits to remote... this can take a while for large uploads.';
|
||||
return tr('gitSidebar.progress.pushingRemote');
|
||||
}
|
||||
if (action === 'fetch') {
|
||||
return 'Fetching remote updates...';
|
||||
return tr('gitSidebar.progress.fetching');
|
||||
}
|
||||
if (action === 'pull') {
|
||||
return 'Pulling latest changes...';
|
||||
return tr('gitSidebar.progress.pulling');
|
||||
}
|
||||
if (action === 'prune-lfs') {
|
||||
return 'Pruning local Git LFS cache...';
|
||||
return tr('gitSidebar.progress.pruningLfs');
|
||||
}
|
||||
return 'Creating commit...';
|
||||
return tr('gitSidebar.progress.committing');
|
||||
};
|
||||
|
||||
const getHistoryStatusLabel = (status: GitHistoryEntry['syncStatus']): string => {
|
||||
if (status === 'local-only') {
|
||||
return 'Local only';
|
||||
return tr('gitSidebar.history.localOnly');
|
||||
}
|
||||
if (status === 'remote-only') {
|
||||
return 'Remote only';
|
||||
return tr('gitSidebar.history.remoteOnly');
|
||||
}
|
||||
return 'Synced';
|
||||
return tr('gitSidebar.history.synced');
|
||||
};
|
||||
|
||||
const openDiffTab = useCallback(
|
||||
@@ -194,7 +196,7 @@ export const GitSidebar: React.FC = () => {
|
||||
try {
|
||||
const availability = await window.electronAPI.git.checkAvailability();
|
||||
if (!availability.gitFound) {
|
||||
setError('Git executable not found. Please install Git and restart the app.');
|
||||
setError(tr('gitSidebar.error.gitMissing'));
|
||||
setIsRepo(false);
|
||||
return;
|
||||
}
|
||||
@@ -203,7 +205,7 @@ export const GitSidebar: React.FC = () => {
|
||||
setProjectPath(resolvedProjectPath);
|
||||
|
||||
if (!resolvedProjectPath) {
|
||||
setError('No active project selected.');
|
||||
setError(tr('gitSidebar.error.noActiveProject'));
|
||||
setIsRepo(false);
|
||||
return;
|
||||
}
|
||||
@@ -228,7 +230,7 @@ export const GitSidebar: React.FC = () => {
|
||||
setRemoteStateError(null);
|
||||
}
|
||||
} catch {
|
||||
setError('Unable to load repository status.');
|
||||
setError(tr('gitSidebar.error.loadRepoStatus'));
|
||||
setIsRepo(false);
|
||||
setHasRemote(false);
|
||||
setStatusFiles([]);
|
||||
@@ -238,7 +240,7 @@ export const GitSidebar: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refreshRemoteState, refreshRepoDetails, resolveProjectPath]);
|
||||
}, [refreshRemoteState, refreshRepoDetails, resolveProjectPath, tr]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRepoState();
|
||||
@@ -297,7 +299,7 @@ export const GitSidebar: React.FC = () => {
|
||||
setInitProgress({
|
||||
phase: 'initializing-repo',
|
||||
progress: 0,
|
||||
message: 'Preparing repository initialization...',
|
||||
message: tr('gitSidebar.progress.preparingInit'),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -306,13 +308,13 @@ export const GitSidebar: React.FC = () => {
|
||||
? await window.electronAPI.git.init(projectPath, normalizedRemoteUrl)
|
||||
: await window.electronAPI.git.init(projectPath);
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to initialize git repository.');
|
||||
setError(result.error || tr('gitSidebar.error.initFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
await loadRepoState();
|
||||
} catch {
|
||||
setError('Failed to initialize git repository.');
|
||||
setError(tr('gitSidebar.error.initFailed'));
|
||||
} finally {
|
||||
setInitializing(false);
|
||||
}
|
||||
@@ -325,7 +327,7 @@ export const GitSidebar: React.FC = () => {
|
||||
|
||||
const effectiveProjectPath = projectPath ?? (await resolveProjectPath());
|
||||
if (!effectiveProjectPath) {
|
||||
setError('No active project selected.');
|
||||
setError(tr('gitSidebar.error.noActiveProject'));
|
||||
return;
|
||||
}
|
||||
if (!projectPath) {
|
||||
@@ -349,13 +351,13 @@ export const GitSidebar: React.FC = () => {
|
||||
recentCommitsToKeep: 2,
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error || `Failed to ${action}.`);
|
||||
setError(result.error || tr('gitSidebar.error.actionFailed', { action }));
|
||||
setErrorGuidance('guidance' in result ? result.guidance || [] : []);
|
||||
return;
|
||||
}
|
||||
await loadRepoState();
|
||||
} catch {
|
||||
setError(`Failed to ${action}.`);
|
||||
setError(tr('gitSidebar.error.actionFailed', { action }));
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
@@ -368,7 +370,7 @@ export const GitSidebar: React.FC = () => {
|
||||
|
||||
const effectiveProjectPath = projectPath ?? (await resolveProjectPath());
|
||||
if (!effectiveProjectPath) {
|
||||
setError('No active project selected.');
|
||||
setError(tr('gitSidebar.error.noActiveProject'));
|
||||
return;
|
||||
}
|
||||
if (!projectPath) {
|
||||
@@ -382,7 +384,7 @@ export const GitSidebar: React.FC = () => {
|
||||
const messageToCommit = commitMessageInputRef.current?.value ?? commitMessage;
|
||||
const result = await window.electronAPI.git.commitAll(effectiveProjectPath, messageToCommit);
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to commit changes.');
|
||||
setError(result.error || tr('gitSidebar.error.commitFailed'));
|
||||
setErrorGuidance(result.guidance || []);
|
||||
return;
|
||||
}
|
||||
@@ -394,7 +396,7 @@ export const GitSidebar: React.FC = () => {
|
||||
setCommitMessage('');
|
||||
await loadRepoState();
|
||||
} catch {
|
||||
setError('Failed to commit changes.');
|
||||
setError(tr('gitSidebar.error.commitFailed'));
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
@@ -403,8 +405,8 @@ export const GitSidebar: React.FC = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="git-sidebar">
|
||||
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
||||
<div className="git-sidebar-empty">Loading...</div>
|
||||
<div className="git-sidebar-header">{tr('gitSidebar.header')}</div>
|
||||
<div className="git-sidebar-empty">{tr('gitSidebar.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -417,7 +419,7 @@ export const GitSidebar: React.FC = () => {
|
||||
onClick={() => setIsTranscriptExpanded((previous) => !previous)}
|
||||
aria-expanded={isTranscriptExpanded}
|
||||
>
|
||||
Initialization transcript
|
||||
{tr('gitSidebar.init.transcript')}
|
||||
</button>
|
||||
{isTranscriptExpanded && (
|
||||
<ul className="git-sidebar-transcript-list">
|
||||
@@ -435,16 +437,16 @@ export const GitSidebar: React.FC = () => {
|
||||
if (isRepo) {
|
||||
return (
|
||||
<div className="git-sidebar">
|
||||
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
||||
<div className="git-sidebar-header">{tr('gitSidebar.header')}</div>
|
||||
<div className="git-sidebar-content">
|
||||
<div className="git-sidebar-actions" role="group" aria-label="Repository actions">
|
||||
<div className="git-sidebar-actions" role="group" aria-label={tr('gitSidebar.aria.repoActions')}>
|
||||
<button
|
||||
type="button"
|
||||
className="git-sidebar-button"
|
||||
onClick={() => handleRepoAction('fetch')}
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
{actionLoading === 'fetch' ? 'Fetching...' : 'Fetch'}
|
||||
{actionLoading === 'fetch' ? tr('gitSidebar.action.fetching') : tr('gitSidebar.action.fetch')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -452,7 +454,7 @@ export const GitSidebar: React.FC = () => {
|
||||
onClick={() => handleRepoAction('pull')}
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
{actionLoading === 'pull' ? 'Pulling...' : 'Pull'}
|
||||
{actionLoading === 'pull' ? tr('gitSidebar.action.pulling') : tr('gitSidebar.action.pull')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -460,7 +462,7 @@ export const GitSidebar: React.FC = () => {
|
||||
onClick={() => handleRepoAction('push')}
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
{actionLoading === 'push' ? 'Pushing...' : 'Push'}
|
||||
{actionLoading === 'push' ? tr('gitSidebar.action.pushing') : tr('gitSidebar.action.push')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -468,7 +470,7 @@ export const GitSidebar: React.FC = () => {
|
||||
onClick={() => handleRepoAction('prune-lfs')}
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
{actionLoading === 'prune-lfs' ? 'Pruning...' : 'Prune LFS'}
|
||||
{actionLoading === 'prune-lfs' ? tr('gitSidebar.action.pruning') : tr('gitSidebar.action.pruneLfs')}
|
||||
</button>
|
||||
</div>
|
||||
{actionLoading && (
|
||||
@@ -478,14 +480,14 @@ export const GitSidebar: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="git-sidebar-section">
|
||||
<div className="sidebar-section-title">Open Changes ({statusFiles.length})</div>
|
||||
<div className="sidebar-section-title">{tr('gitSidebar.openChanges', { count: statusFiles.length })}</div>
|
||||
|
||||
<div className="git-sidebar-commit-row">
|
||||
<input
|
||||
ref={commitMessageInputRef}
|
||||
className="git-sidebar-input"
|
||||
type="text"
|
||||
placeholder="Commit message"
|
||||
placeholder={tr('gitSidebar.placeholder.commitMessage')}
|
||||
value={commitMessage}
|
||||
onChange={(event) => setCommitMessage(event.target.value)}
|
||||
disabled={actionLoading !== null}
|
||||
@@ -496,16 +498,16 @@ export const GitSidebar: React.FC = () => {
|
||||
onClick={handleCommit}
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
{actionLoading === 'commit' ? 'Committing...' : 'Commit'}
|
||||
{actionLoading === 'commit' ? tr('gitSidebar.action.committing') : tr('gitSidebar.action.commit')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{statusLoading ? (
|
||||
<div className="git-sidebar-empty-state">Loading changes...</div>
|
||||
<div className="git-sidebar-empty-state">{tr('gitSidebar.loadingChanges')}</div>
|
||||
) : statusFiles.length === 0 ? (
|
||||
<div className="git-sidebar-empty-state">No changes</div>
|
||||
<div className="git-sidebar-empty-state">{tr('gitSidebar.noChanges')}</div>
|
||||
) : (
|
||||
<div className="git-sidebar-file-list" role="list" aria-label="Open Changes">
|
||||
<div className="git-sidebar-file-list" role="list" aria-label={tr('gitSidebar.aria.openChanges')}>
|
||||
{statusFiles.map((file) => (
|
||||
<button
|
||||
key={file.path}
|
||||
@@ -524,36 +526,36 @@ export const GitSidebar: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="git-sidebar-section git-sidebar-history">
|
||||
<div className="sidebar-section-title">Version History ({historyEntries.length})</div>
|
||||
<div className="git-sidebar-history-legend" aria-label="Commit status legend">
|
||||
<div className="sidebar-section-title">{tr('gitSidebar.versionHistory', { count: historyEntries.length })}</div>
|
||||
<div className="git-sidebar-history-legend" aria-label={tr('gitSidebar.aria.commitStatusLegend')}>
|
||||
<span className="git-sidebar-history-legend-item">
|
||||
<span
|
||||
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--both"
|
||||
data-testid="git-history-legend-both"
|
||||
/>
|
||||
Synced
|
||||
{tr('gitSidebar.history.synced')}
|
||||
</span>
|
||||
<span className="git-sidebar-history-legend-item">
|
||||
<span
|
||||
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--local-only"
|
||||
data-testid="git-history-legend-local-only"
|
||||
/>
|
||||
Local only
|
||||
{tr('gitSidebar.history.localOnly')}
|
||||
</span>
|
||||
<span className="git-sidebar-history-legend-item">
|
||||
<span
|
||||
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--remote-only"
|
||||
data-testid="git-history-legend-remote-only"
|
||||
/>
|
||||
Remote only
|
||||
{tr('gitSidebar.history.remoteOnly')}
|
||||
</span>
|
||||
</div>
|
||||
{historyLoading ? (
|
||||
<div className="git-sidebar-empty-state">Loading history...</div>
|
||||
<div className="git-sidebar-empty-state">{tr('gitSidebar.loadingHistory')}</div>
|
||||
) : historyEntries.length === 0 ? (
|
||||
<div className="git-sidebar-empty-state">No commits yet</div>
|
||||
<div className="git-sidebar-empty-state">{tr('gitSidebar.noCommits')}</div>
|
||||
) : (
|
||||
<div className="git-sidebar-history-list" role="list" aria-label="Version History">
|
||||
<div className="git-sidebar-history-list" role="list" aria-label={tr('gitSidebar.aria.versionHistory')}>
|
||||
{historyEntries.map((entry) => (
|
||||
<button
|
||||
key={entry.hash}
|
||||
@@ -576,12 +578,12 @@ export const GitSidebar: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{currentBranch && <div className="git-sidebar-empty-state">Branch: {currentBranch}</div>}
|
||||
{currentBranch && <div className="git-sidebar-empty-state">{tr('gitSidebar.branch', { branch: currentBranch })}</div>}
|
||||
{remoteState?.hasUpstream && remoteState.localBranch && remoteState.upstreamBranch && (
|
||||
<div className="git-sidebar-empty-state">{remoteState.localBranch} → {remoteState.upstreamBranch}</div>
|
||||
)}
|
||||
{remoteState?.hasUpstream && (
|
||||
<div className="git-sidebar-empty-state">ahead {remoteState.ahead} / behind {remoteState.behind}</div>
|
||||
<div className="git-sidebar-empty-state">{tr('gitSidebar.aheadBehind', { ahead: remoteState.ahead, behind: remoteState.behind })}</div>
|
||||
)}
|
||||
{remoteStateError && <div className="git-sidebar-empty-state git-sidebar-error">{remoteStateError}</div>}
|
||||
</div>
|
||||
@@ -605,20 +607,20 @@ export const GitSidebar: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="git-sidebar">
|
||||
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
||||
<div className="git-sidebar-header">{tr('gitSidebar.header')}</div>
|
||||
<div className="git-sidebar-empty">
|
||||
<div className="git-sidebar-main">
|
||||
<p>This project is not a git repository.</p>
|
||||
<p>{tr('gitSidebar.notRepo')}</p>
|
||||
<input
|
||||
ref={remoteUrlInputRef}
|
||||
className="git-sidebar-input"
|
||||
type="text"
|
||||
placeholder="Optional remote repository URL"
|
||||
placeholder={tr('gitSidebar.placeholder.remoteUrl')}
|
||||
disabled={initializing}
|
||||
/>
|
||||
{initializing && (
|
||||
<p className="git-sidebar-progress">
|
||||
{initProgress?.message || 'Initializing repository...'}
|
||||
{initProgress?.message || tr('gitSidebar.progress.initializingRepo')}
|
||||
{typeof initProgress?.progress === 'number' ? ` (${initProgress.progress}%)` : ''}
|
||||
{initProgress?.detail ? ` — ${initProgress.detail}` : ''}
|
||||
</p>
|
||||
@@ -629,7 +631,7 @@ export const GitSidebar: React.FC = () => {
|
||||
onClick={handleInitialize}
|
||||
disabled={initializing || !projectPath}
|
||||
>
|
||||
{initializing ? 'Initializing...' : 'Initialize Git'}
|
||||
{initializing ? tr('gitSidebar.action.initializing') : tr('gitSidebar.action.initializeGit')}
|
||||
</button>
|
||||
</div>
|
||||
{transcriptSection}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './InsertModal.css';
|
||||
|
||||
interface PostSearchResult {
|
||||
@@ -54,6 +55,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
||||
onClose,
|
||||
initialText = '',
|
||||
}) => {
|
||||
const { t: tr } = useI18n();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('internal');
|
||||
const [query, setQuery] = useState('');
|
||||
const [externalUrl, setExternalUrl] = useState('');
|
||||
@@ -164,10 +166,10 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
||||
onInsertLink(externalUrl, externalText || undefined);
|
||||
} else {
|
||||
// External images don't have a mediaId
|
||||
onInsertImage(externalUrl, externalAlt || 'Image', undefined);
|
||||
onInsertImage(externalUrl, externalAlt || tr('insert.title.image'), undefined);
|
||||
}
|
||||
onClose();
|
||||
}, [mode, externalUrl, externalText, externalAlt, onInsertLink, onInsertImage, onClose]);
|
||||
}, [mode, externalUrl, externalText, externalAlt, onInsertLink, onInsertImage, onClose, tr]);
|
||||
|
||||
// Backdrop click handler
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||
@@ -184,12 +186,12 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
const title = mode === 'link' ? 'Insert Link' : 'Insert Image';
|
||||
const internalLabel = mode === 'link' ? 'Link to Post' : 'Media Library';
|
||||
const externalLabel = mode === 'link' ? 'External URL' : 'External Image';
|
||||
const title = mode === 'link' ? tr('insert.title.link') : tr('insert.title.image');
|
||||
const internalLabel = mode === 'link' ? tr('insert.tab.linkInternal') : tr('insert.tab.imageInternal');
|
||||
const externalLabel = mode === 'link' ? tr('insert.tab.linkExternal') : tr('insert.tab.imageExternal');
|
||||
const searchPlaceholder = mode === 'link'
|
||||
? 'Search posts by title or content...'
|
||||
: 'Search media by name, title, or alt text...';
|
||||
? tr('insert.searchPlaceholder.link')
|
||||
: tr('insert.searchPlaceholder.image');
|
||||
|
||||
return (
|
||||
<div className="insert-modal-backdrop" onClick={handleBackdropClick}>
|
||||
@@ -228,18 +230,18 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
||||
|
||||
<div className="insert-modal-results">
|
||||
{isSearching && (
|
||||
<div className="insert-modal-status">Searching...</div>
|
||||
<div className="insert-modal-status">{tr('insert.status.searching')}</div>
|
||||
)}
|
||||
|
||||
{!isSearching && query.length < 2 && (
|
||||
<div className="insert-modal-status">
|
||||
Type at least 2 characters to search
|
||||
{tr('insert.status.typeMore')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && query.length >= 2 && results.length === 0 && (
|
||||
<div className="insert-modal-status">
|
||||
No {mode === 'link' ? 'posts' : 'media'} found for "{query}"
|
||||
{tr('insert.status.noResults', { kind: mode === 'link' ? tr('activity.posts').toLowerCase() : tr('activity.media').toLowerCase(), query })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -277,12 +279,12 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
||||
) : (
|
||||
<div className="insert-modal-external">
|
||||
<div className="insert-modal-field">
|
||||
<label className="insert-modal-label">URL</label>
|
||||
<label className="insert-modal-label">{tr('insert.label.url')}</label>
|
||||
<input
|
||||
ref={externalUrlRef}
|
||||
type="text"
|
||||
className="insert-modal-input"
|
||||
placeholder={mode === 'link' ? 'https://example.com' : 'https://example.com/image.jpg'}
|
||||
placeholder={mode === 'link' ? tr('insert.placeholder.linkUrl') : tr('insert.placeholder.imageUrl')}
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
autoComplete="off"
|
||||
@@ -291,22 +293,22 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
||||
|
||||
{mode === 'link' ? (
|
||||
<div className="insert-modal-field">
|
||||
<label className="insert-modal-label">Link Text (optional)</label>
|
||||
<label className="insert-modal-label">{tr('insert.label.linkTextOptional')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="insert-modal-input"
|
||||
placeholder="Click here"
|
||||
placeholder={tr('insert.placeholder.linkText')}
|
||||
value={externalText}
|
||||
onChange={(e) => setExternalText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="insert-modal-field">
|
||||
<label className="insert-modal-label">Alt Text</label>
|
||||
<label className="insert-modal-label">{tr('insert.label.altText')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="insert-modal-input"
|
||||
placeholder="Description of the image"
|
||||
placeholder={tr('insert.placeholder.imageAlt')}
|
||||
value={externalAlt}
|
||||
onChange={(e) => setExternalAlt(e.target.value)}
|
||||
/>
|
||||
@@ -318,7 +320,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
||||
onClick={handleExternalSubmit}
|
||||
disabled={!externalUrl}
|
||||
>
|
||||
Insert {mode === 'link' ? 'Link' : 'Image'}
|
||||
{mode === 'link' ? tr('insert.submit.link') : tr('insert.submit.image')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -327,14 +329,14 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
||||
<div className="insert-modal-footer-content">
|
||||
<span className="insert-modal-hint">
|
||||
{activeTab === 'internal'
|
||||
? 'Use ↑↓ to navigate, Enter to select, Esc to close'
|
||||
: 'Enter URL and press Enter or click button, Esc to close'}
|
||||
? tr('insert.hint.internal')
|
||||
: tr('insert.hint.external')}
|
||||
</span>
|
||||
{activeTab === 'internal' && (
|
||||
<span className="insert-modal-format-hint">
|
||||
{mode === 'link'
|
||||
? 'Canonical: /YYYY/MM/DD/slug'
|
||||
: 'Canonical: /media/YYYY/MM/file.ext'}
|
||||
? tr('insert.hint.canonicalPost')
|
||||
: tr('insert.hint.canonicalMedia')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './Lightbox.css';
|
||||
|
||||
interface LightboxImage {
|
||||
@@ -20,6 +21,7 @@ export const Lightbox: React.FC<LightboxProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t: tr } = useI18n();
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
|
||||
@@ -88,7 +90,7 @@ export const Lightbox: React.FC<LightboxProps> = ({
|
||||
<div className="lightbox-overlay" onClick={handleBackdropClick}>
|
||||
<div className="lightbox-container">
|
||||
{/* Close button */}
|
||||
<button className="lightbox-close" onClick={onClose} title="Close (Esc)">
|
||||
<button className="lightbox-close" onClick={onClose} title={tr('lightbox.close')}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||
</svg>
|
||||
@@ -97,12 +99,12 @@ export const Lightbox: React.FC<LightboxProps> = ({
|
||||
{/* Navigation arrows */}
|
||||
{hasMultiple && (
|
||||
<>
|
||||
<button className="lightbox-nav lightbox-prev" onClick={handlePrev} title="Previous (←)">
|
||||
<button className="lightbox-nav lightbox-prev" onClick={handlePrev} title={tr('lightbox.previous')}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button className="lightbox-nav lightbox-next" onClick={handleNext} title="Next (→)">
|
||||
<button className="lightbox-nav lightbox-next" onClick={handleNext} title={tr('lightbox.next')}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
|
||||
</svg>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { showToast } from '../Toast';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './MetadataDiffPanel.css';
|
||||
|
||||
interface TableStats {
|
||||
@@ -40,6 +41,7 @@ interface ScanResult {
|
||||
type ScanPhase = 'idle' | 'loading-stats' | 'scanning' | 'complete';
|
||||
|
||||
export const MetadataDiffPanel: React.FC = () => {
|
||||
const { t: tr } = useI18n();
|
||||
const [stats, setStats] = useState<TableStats | null>(null);
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [scanPhase, setScanPhase] = useState<ScanPhase>('idle');
|
||||
@@ -58,12 +60,12 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error);
|
||||
showToast.error('Failed to load database statistics');
|
||||
showToast.error(tr('metadataDiff.error.loadStats'));
|
||||
}
|
||||
setScanPhase('idle');
|
||||
};
|
||||
loadStats();
|
||||
}, []);
|
||||
}, [tr]);
|
||||
|
||||
// Subscribe to task progress
|
||||
useEffect(() => {
|
||||
@@ -85,7 +87,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
|
||||
const handleScan = useCallback(async () => {
|
||||
setScanPhase('scanning');
|
||||
setProgress({ current: 0, total: 100, message: 'Starting scan...' });
|
||||
setProgress({ current: 0, total: 100, message: tr('metadataDiff.progress.starting') });
|
||||
setScanResult(null);
|
||||
|
||||
try {
|
||||
@@ -99,10 +101,10 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
setScanPhase('complete');
|
||||
} catch (error) {
|
||||
console.error('Scan failed:', error);
|
||||
showToast.error('Failed to scan for differences');
|
||||
showToast.error(tr('metadataDiff.error.scan'));
|
||||
setScanPhase('idle');
|
||||
}
|
||||
}, []);
|
||||
}, [tr]);
|
||||
|
||||
const toggleGroup = (field: string) => {
|
||||
setExpandedGroups(prev => {
|
||||
@@ -123,13 +125,13 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.metadataDiff.syncDbToFile(postIds, group.label);
|
||||
if (result) {
|
||||
showToast.success(`Synced ${result.success} posts to files${result.failed > 0 ? `, ${result.failed} failed` : ''}`);
|
||||
showToast.success(tr('metadataDiff.sync.dbToFile.success', { success: result.success, failed: result.failed > 0 ? `, ${result.failed} ${tr('metadataDiff.sync.failed')}` : '' }));
|
||||
// Re-scan to update the view
|
||||
handleScan();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error);
|
||||
showToast.error('Failed to sync to files');
|
||||
showToast.error(tr('metadataDiff.sync.dbToFile.error'));
|
||||
} finally {
|
||||
setSyncingGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -137,7 +139,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [handleScan]);
|
||||
}, [handleScan, tr]);
|
||||
|
||||
const handleSyncFileToDb = useCallback(async (group: DiffGroup) => {
|
||||
const postIds = group.posts.map(p => p.postId);
|
||||
@@ -146,13 +148,13 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.metadataDiff.syncFileToDb(postIds, group.field, group.label);
|
||||
if (result) {
|
||||
showToast.success(`Synced ${result.success} files to database${result.failed > 0 ? `, ${result.failed} failed` : ''}`);
|
||||
showToast.success(tr('metadataDiff.sync.fileToDb.success', { success: result.success, failed: result.failed > 0 ? `, ${result.failed} ${tr('metadataDiff.sync.failed')}` : '' }));
|
||||
// Re-scan to update the view
|
||||
handleScan();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error);
|
||||
showToast.error('Failed to sync to database');
|
||||
showToast.error(tr('metadataDiff.sync.fileToDb.error'));
|
||||
} finally {
|
||||
setSyncingGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -160,7 +162,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [handleScan]);
|
||||
}, [handleScan, tr]);
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (Array.isArray(value)) {
|
||||
@@ -174,28 +176,28 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="metadata-diff-panel">
|
||||
<h2>Metadata Diff Tool</h2>
|
||||
<h2>{tr('metadataDiff.title')}</h2>
|
||||
<p style={{ marginBottom: 16, color: 'var(--descriptionForeground)', fontSize: 13 }}>
|
||||
Compare post metadata between database and markdown files. Fix inconsistencies caused by bugs or manual edits.
|
||||
{tr('metadataDiff.description')}
|
||||
</p>
|
||||
|
||||
{/* Stats Section */}
|
||||
{stats && (
|
||||
<div className="diff-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Total Posts</span>
|
||||
<span className="stat-label">{tr('metadataDiff.stats.totalPosts')}</span>
|
||||
<span className="stat-value">{stats.totalPosts}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Published</span>
|
||||
<span className="stat-label">{tr('metadataDiff.stats.published')}</span>
|
||||
<span className="stat-value">{stats.publishedPosts}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Drafts</span>
|
||||
<span className="stat-label">{tr('metadataDiff.stats.drafts')}</span>
|
||||
<span className="stat-value">{stats.draftPosts}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Media Files</span>
|
||||
<span className="stat-label">{tr('metadataDiff.stats.mediaFiles')}</span>
|
||||
<span className="stat-value">{stats.totalMedia}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,7 +206,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
{/* Progress Section */}
|
||||
{scanPhase === 'scanning' && (
|
||||
<div className="diff-progress">
|
||||
<h3>Scanning published posts...</h3>
|
||||
<h3>{tr('metadataDiff.progress.scanningPublished')}</h3>
|
||||
<div className="progress-bar-container">
|
||||
<div
|
||||
className="progress-bar"
|
||||
@@ -225,12 +227,12 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
{scanPhase === 'scanning' ? (
|
||||
<>
|
||||
<span className="spinner" style={{ width: 14, height: 14 }} />
|
||||
Scanning...
|
||||
{tr('metadataDiff.progress.scanning')}
|
||||
</>
|
||||
) : scanResult ? (
|
||||
'🔄 Re-scan'
|
||||
`🔄 ${tr('metadataDiff.action.rescan')}`
|
||||
) : (
|
||||
'🔍 Scan for Differences'
|
||||
`🔍 ${tr('metadataDiff.action.scan')}`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -240,11 +242,10 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
<div className="diff-results">
|
||||
<div className={`diff-summary ${scanResult.postsWithDifferences > 0 ? 'has-differences' : 'no-differences'}`}>
|
||||
{scanResult.postsWithDifferences === 0 ? (
|
||||
<>✅ No differences found! All {scanResult.totalScanned} published posts are in sync.</>
|
||||
<>{tr('metadataDiff.summary.noDiffs', { total: scanResult.totalScanned })}</>
|
||||
) : (
|
||||
<>
|
||||
⚠️ Found <strong>{scanResult.postsWithDifferences}</strong> posts with differences
|
||||
out of {scanResult.totalScanned} published posts.
|
||||
{tr('metadataDiff.summary.withDiffs', { count: scanResult.postsWithDifferences, total: scanResult.totalScanned })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -260,16 +261,16 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
<span className={`chevron ${expandedGroups.has(group.field) ? 'expanded' : ''}`}>
|
||||
▶
|
||||
</span>
|
||||
{group.label} Differences
|
||||
{tr('metadataDiff.group.differences', { label: group.label })}
|
||||
</div>
|
||||
<div className="diff-group-count">
|
||||
<span className="badge">{group.posts.length} posts</span>
|
||||
<span className="badge">{tr('metadataDiff.group.postsCount', { count: group.posts.length })}</span>
|
||||
<div className="diff-group-actions" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="db-to-file"
|
||||
onClick={() => handleSyncDbToFile(group)}
|
||||
disabled={syncingGroups.has(group.field)}
|
||||
title="Update files with database values"
|
||||
title={tr('metadataDiff.sync.dbToFile.title')}
|
||||
>
|
||||
DB → File
|
||||
</button>
|
||||
@@ -277,7 +278,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
className="file-to-db"
|
||||
onClick={() => handleSyncFileToDb(group)}
|
||||
disabled={syncingGroups.has(group.field)}
|
||||
title="Update database with file values"
|
||||
title={tr('metadataDiff.sync.fileToDb.title')}
|
||||
>
|
||||
File → DB
|
||||
</button>
|
||||
@@ -291,13 +292,13 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
{post.title || post.slug}
|
||||
</div>
|
||||
<div>
|
||||
<div className="diff-value-label">Database</div>
|
||||
<div className="diff-value-label">{tr('metadataDiff.value.database')}</div>
|
||||
<div className="diff-value db-value" title={formatValue(post.dbValue)}>
|
||||
{formatValue(post.dbValue)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="diff-value-label">File</div>
|
||||
<div className="diff-value-label">{tr('metadataDiff.value.file')}</div>
|
||||
<div className="diff-value file-value" title={formatValue(post.fileValue)}>
|
||||
{formatValue(post.fileValue)}
|
||||
</div>
|
||||
@@ -314,7 +315,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
||||
{scanPhase === 'idle' && !scanResult && (
|
||||
<div className="diff-empty">
|
||||
<div className="icon">📊</div>
|
||||
<div>Click "Scan for Differences" to compare database metadata with file metadata.</div>
|
||||
<div>{tr('metadataDiff.empty')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import '../../macros';
|
||||
import './MilkdownEditor.css';
|
||||
import { InsertModal } from '../InsertModal';
|
||||
import { normalizeMilkdownMarkdown } from '../../utils/markdownEscape';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
// Remark plugin to force tight lists (no blank lines between list items)
|
||||
const remarkTightListsPlugin: Plugin<[Record<string, unknown>], Root> = () => {
|
||||
@@ -89,6 +90,7 @@ interface EditorToolbarProps {
|
||||
|
||||
// Toolbar component that uses the editor instance
|
||||
const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
|
||||
const { t: tr } = useI18n();
|
||||
const [loading, getEditor] = useInstance();
|
||||
const [insertMode, setInsertMode] = useState<InsertModalMode>(null);
|
||||
const [selectedText, setSelectedText] = useState('');
|
||||
@@ -218,21 +220,21 @@ const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
|
||||
<>
|
||||
<div className="milkdown-toolbar">
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => insertHeading(1)} title="Heading 1">H1</button>
|
||||
<button onClick={() => insertHeading(2)} title="Heading 2">H2</button>
|
||||
<button onClick={() => insertHeading(3)} title="Heading 3">H3</button>
|
||||
<button onClick={() => insertHeading(1)} title={tr('milkdown.heading1')}>H1</button>
|
||||
<button onClick={() => insertHeading(2)} title={tr('milkdown.heading2')}>H2</button>
|
||||
<button onClick={() => insertHeading(3)} title={tr('milkdown.heading3')}>H3</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => runCommand(toggleStrongCommand.key)} title="Bold (Ctrl+B)">
|
||||
<button onClick={() => runCommand(toggleStrongCommand.key)} title={tr('milkdown.bold')}>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button onClick={() => runCommand(toggleEmphasisCommand.key)} title="Italic (Ctrl+I)">
|
||||
<button onClick={() => runCommand(toggleEmphasisCommand.key)} title={tr('milkdown.italic')}>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button onClick={() => runCommand(toggleStrikethroughCommand.key)} title="Strikethrough">
|
||||
<button onClick={() => runCommand(toggleStrikethroughCommand.key)} title={tr('milkdown.strikethrough')}>
|
||||
<s>S</s>
|
||||
</button>
|
||||
</div>
|
||||
@@ -240,25 +242,25 @@ const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => runCommand(wrapInBulletListCommand.key)} title="Bullet List">•</button>
|
||||
<button onClick={() => runCommand(wrapInOrderedListCommand.key)} title="Numbered List">1.</button>
|
||||
<button onClick={() => runCommand(wrapInBlockquoteCommand.key)} title="Quote">❝</button>
|
||||
<button onClick={() => runCommand(toggleInlineCodeCommand.key)} title="Code">{'{}'}</button>
|
||||
<button onClick={() => runCommand(wrapInBulletListCommand.key)} title={tr('milkdown.bulletList')}>•</button>
|
||||
<button onClick={() => runCommand(wrapInOrderedListCommand.key)} title={tr('milkdown.numberedList')}>1.</button>
|
||||
<button onClick={() => runCommand(wrapInBlockquoteCommand.key)} title={tr('milkdown.quote')}>❝</button>
|
||||
<button onClick={() => runCommand(toggleInlineCodeCommand.key)} title={tr('milkdown.code')}>{'{}'}</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={openLinkModal} title="Insert Link (Ctrl+K)">🔗</button>
|
||||
<button onClick={openImageModal} title="Insert Image">🖼</button>
|
||||
<button onClick={() => runCommand(insertHrCommand.key)} title="Horizontal Rule">―</button>
|
||||
<button onClick={openLinkModal} title={tr('milkdown.insertLink')}>🔗</button>
|
||||
<button onClick={openImageModal} title={tr('milkdown.insertImage')}>🖼</button>
|
||||
<button onClick={() => runCommand(insertHrCommand.key)} title={tr('milkdown.horizontalRule')}>―</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button onClick={() => runCommand(undoCommand.key)} title="Undo (Ctrl+Z)">↶</button>
|
||||
<button onClick={() => runCommand(redoCommand.key)} title="Redo (Ctrl+Y)">↷</button>
|
||||
<button onClick={() => runCommand(undoCommand.key)} title={tr('milkdown.undo')}>↶</button>
|
||||
<button onClick={() => runCommand(redoCommand.key)} title={tr('milkdown.redo')}>↷</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -288,8 +290,10 @@ export const MilkdownEditor: React.FC<MilkdownEditorProps> = (props) => {
|
||||
const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
|
||||
content,
|
||||
onChange,
|
||||
placeholder = 'Start writing your content...',
|
||||
placeholder,
|
||||
}) => {
|
||||
const { t: tr } = useI18n();
|
||||
const resolvedPlaceholder = placeholder || tr('editor.placeholder');
|
||||
const [loading, getEditor] = useInstance();
|
||||
const lastExternalContent = useRef(content);
|
||||
const isInternalChange = useRef(false);
|
||||
@@ -375,7 +379,7 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
|
||||
onInputCapture={markUserInteraction}
|
||||
>
|
||||
<EditorToolbar onUserInteraction={markUserInteraction} />
|
||||
<div className="milkdown-content" data-placeholder={placeholder}>
|
||||
<div className="milkdown-content" data-placeholder={resolvedPlaceholder}>
|
||||
<Milkdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './PostLinks.css';
|
||||
|
||||
interface PostLinkInfo {
|
||||
@@ -14,6 +15,7 @@ interface PostLinksProps {
|
||||
}
|
||||
|
||||
export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updatedAt }) => {
|
||||
const { t: tr } = useI18n();
|
||||
const [linksTo, setLinksTo] = useState<PostLinkInfo[]>([]);
|
||||
const [linkedBy, setLinkedBy] = useState<PostLinkInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -44,7 +46,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="post-links">
|
||||
<div className="post-links-loading">Loading links...</div>
|
||||
<div className="post-links-loading">{tr('postLinks.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +62,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className="post-links-icon">🔗</span>
|
||||
<span className="post-links-count">{totalLinks} link{totalLinks !== 1 ? 's' : ''}</span>
|
||||
<span className="post-links-count">{totalLinks} {totalLinks !== 1 ? tr('postLinks.links') : tr('postLinks.link')}</span>
|
||||
<span className="post-links-chevron">{expanded ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
|
||||
@@ -70,7 +72,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
|
||||
<div className="post-links-section">
|
||||
<h4 className="post-links-heading">
|
||||
<span className="post-links-arrow">→</span>
|
||||
Links to ({linksTo.length})
|
||||
{tr('postLinks.linksTo', { count: linksTo.length })}
|
||||
</h4>
|
||||
<ul className="post-links-list">
|
||||
{linksTo.map(link => (
|
||||
@@ -78,7 +80,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
|
||||
<button
|
||||
className="post-link-item"
|
||||
onClick={() => onPostClick?.(link.id)}
|
||||
title={`Open: ${link.title}`}
|
||||
title={tr('postLinks.openTitle', { title: link.title })}
|
||||
>
|
||||
{link.title}
|
||||
</button>
|
||||
@@ -92,7 +94,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
|
||||
<div className="post-links-section">
|
||||
<h4 className="post-links-heading">
|
||||
<span className="post-links-arrow">←</span>
|
||||
Linked by ({linkedBy.length})
|
||||
{tr('postLinks.linkedBy', { count: linkedBy.length })}
|
||||
</h4>
|
||||
<ul className="post-links-list">
|
||||
{linkedBy.map(link => (
|
||||
@@ -100,7 +102,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
|
||||
<button
|
||||
className="post-link-item"
|
||||
onClick={() => onPostClick?.(link.id)}
|
||||
title={`Open: ${link.title}`}
|
||||
title={tr('postLinks.openTitle', { title: link.title })}
|
||||
>
|
||||
{link.title}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './SettingsView.css';
|
||||
|
||||
// Export category IDs for sidebar navigation
|
||||
@@ -102,6 +103,7 @@ const SettingSection: React.FC<{
|
||||
};
|
||||
|
||||
export const SettingsView: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
preferredEditorMode,
|
||||
setPreferredEditorMode,
|
||||
@@ -255,10 +257,10 @@ export const SettingsView: React.FC = () => {
|
||||
const handleSavePublishing = async () => {
|
||||
try {
|
||||
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
|
||||
showToast.success('Publishing credentials saved');
|
||||
showToast.success(t('settings.toast.publishingSaved'));
|
||||
} catch (error) {
|
||||
console.error('Failed to save publishing credentials:', error);
|
||||
showToast.error('Failed to save credentials');
|
||||
showToast.error(t('settings.toast.saveCredentialsFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -278,7 +280,7 @@ export const SettingsView: React.FC = () => {
|
||||
}
|
||||
setCredentials(newCreds);
|
||||
localStorage.setItem('bds-credentials', JSON.stringify(newCreds));
|
||||
showToast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} credentials cleared`);
|
||||
showToast.success(t('settings.toast.credentialsCleared', { type: type.toUpperCase() }));
|
||||
};
|
||||
|
||||
// Save project settings
|
||||
@@ -306,10 +308,10 @@ export const SettingsView: React.FC = () => {
|
||||
categorySettings,
|
||||
});
|
||||
}
|
||||
showToast.success('Project settings saved');
|
||||
showToast.success(t('settings.toast.projectSaved'));
|
||||
} catch (error) {
|
||||
console.error('Failed to save project settings:', error);
|
||||
showToast.error('Failed to save project settings');
|
||||
showToast.error(t('settings.toast.projectSaveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -335,7 +337,7 @@ export const SettingsView: React.FC = () => {
|
||||
const renderProjectSettings = () => (
|
||||
<SettingSection
|
||||
id="settings-section-project"
|
||||
title="Project"
|
||||
title={t('settings.project.title')}
|
||||
description="General settings for the active blog project."
|
||||
hidden={!sectionHasMatches(projectKeywords)}
|
||||
>
|
||||
@@ -380,12 +382,12 @@ export const SettingsView: React.FC = () => {
|
||||
value={projectDataPath}
|
||||
onChange={(e) => setProjectDataPath(e.target.value)}
|
||||
/>
|
||||
<button className="secondary" onClick={handleBrowseDataPath} title="Browse...">
|
||||
Browse
|
||||
<button className="secondary" onClick={handleBrowseDataPath} title={t('settings.project.browse')}>
|
||||
{t('settings.project.browse')}
|
||||
</button>
|
||||
{projectDataPath && (
|
||||
<button className="secondary" onClick={handleResetDataPath} title="Reset to default">
|
||||
Reset
|
||||
<button className="secondary" onClick={handleResetDataPath} title={t('settings.project.resetDefault')}>
|
||||
{t('settings.project.reset')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -415,26 +417,26 @@ export const SettingsView: React.FC = () => {
|
||||
value={projectMainLanguage}
|
||||
onChange={(e) => setProjectMainLanguage(e.target.value)}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="de">German (Deutsch)</option>
|
||||
<option value="es">Spanish (Español)</option>
|
||||
<option value="fr">French (Français)</option>
|
||||
<option value="it">Italian (Italiano)</option>
|
||||
<option value="pt">Portuguese (Português)</option>
|
||||
<option value="nl">Dutch (Nederlands)</option>
|
||||
<option value="pl">Polish (Polski)</option>
|
||||
<option value="ru">Russian (Русский)</option>
|
||||
<option value="ja">Japanese (日本語)</option>
|
||||
<option value="zh">Chinese (中文)</option>
|
||||
<option value="ko">Korean (한국어)</option>
|
||||
<option value="ar">Arabic (العربية)</option>
|
||||
<option value="hi">Hindi (हिन्दी)</option>
|
||||
<option value="tr">Turkish (Türkçe)</option>
|
||||
<option value="sv">Swedish (Svenska)</option>
|
||||
<option value="da">Danish (Dansk)</option>
|
||||
<option value="no">Norwegian (Norsk)</option>
|
||||
<option value="fi">Finnish (Suomi)</option>
|
||||
<option value="cs">Czech (Čeština)</option>
|
||||
<option value="en">{t('settings.language.english')}</option>
|
||||
<option value="de">{t('settings.language.german')}</option>
|
||||
<option value="es">{t('settings.language.spanish')}</option>
|
||||
<option value="fr">{t('settings.language.french')}</option>
|
||||
<option value="it">{t('settings.language.italian')}</option>
|
||||
<option value="pt">{t('settings.language.portuguese')}</option>
|
||||
<option value="nl">{t('settings.language.dutch')}</option>
|
||||
<option value="pl">{t('settings.language.polish')}</option>
|
||||
<option value="ru">{t('settings.language.russian')}</option>
|
||||
<option value="ja">{t('settings.language.japanese')}</option>
|
||||
<option value="zh">{t('settings.language.chinese')}</option>
|
||||
<option value="ko">{t('settings.language.korean')}</option>
|
||||
<option value="ar">{t('settings.language.arabic')}</option>
|
||||
<option value="hi">{t('settings.language.hindi')}</option>
|
||||
<option value="tr">{t('settings.language.turkish')}</option>
|
||||
<option value="sv">{t('settings.language.swedish')}</option>
|
||||
<option value="da">{t('settings.language.danish')}</option>
|
||||
<option value="no">{t('settings.language.norwegian')}</option>
|
||||
<option value="fi">{t('settings.language.finnish')}</option>
|
||||
<option value="cs">{t('settings.language.czech')}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
@@ -485,7 +487,7 @@ export const SettingsView: React.FC = () => {
|
||||
const renderEditorSettings = () => (
|
||||
<SettingSection
|
||||
id="settings-section-editor"
|
||||
title="Editor"
|
||||
title={t('settings.editor.title')}
|
||||
description="Configure the blog post editor behavior and appearance."
|
||||
hidden={!sectionHasMatches(editorKeywords)}
|
||||
>
|
||||
@@ -499,9 +501,9 @@ export const SettingsView: React.FC = () => {
|
||||
value={preferredEditorMode}
|
||||
onChange={(e) => setPreferredEditorMode(e.target.value as 'wysiwyg' | 'markdown' | 'preview')}
|
||||
>
|
||||
<option value="wysiwyg">WYSIWYG (Visual Editor)</option>
|
||||
<option value="markdown">Markdown (Source)</option>
|
||||
<option value="preview">Preview (Read-only)</option>
|
||||
<option value="wysiwyg">{t('settings.editor.mode.wysiwyg')}</option>
|
||||
<option value="markdown">{t('settings.editor.mode.markdown')}</option>
|
||||
<option value="preview">{t('settings.editor.mode.preview')}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
@@ -521,8 +523,8 @@ export const SettingsView: React.FC = () => {
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="inline">Inline</option>
|
||||
<option value="side-by-side">Side by Side</option>
|
||||
<option value="inline">{t('settings.editor.diff.inline')}</option>
|
||||
<option value="side-by-side">{t('settings.editor.diff.sideBySide')}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
@@ -582,23 +584,23 @@ export const SettingsView: React.FC = () => {
|
||||
setCategorySettings(nextSettings);
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
||||
setNewCategoryInput('');
|
||||
showToast.success(`Category "${trimmed}" added`);
|
||||
showToast.success(t('settings.toast.categoryAdded', { category: trimmed }));
|
||||
} catch (error) {
|
||||
console.error('Failed to add category:', error);
|
||||
showToast.error('Failed to add category');
|
||||
showToast.error(t('settings.toast.categoryAddFailed'));
|
||||
}
|
||||
} else if (postCategories.includes(trimmed)) {
|
||||
showToast.error('Category already exists');
|
||||
showToast.error(t('settings.toast.categoryExists'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCategory = async (categoryToRemove: string) => {
|
||||
if (PROTECTED_CATEGORIES.includes(categoryToRemove)) {
|
||||
showToast.error(`Cannot delete standard category "${categoryToRemove}"`);
|
||||
showToast.error(t('settings.toast.categoryProtected', { category: categoryToRemove }));
|
||||
return;
|
||||
}
|
||||
if (postCategories.length <= 1) {
|
||||
showToast.error('Must have at least one category');
|
||||
showToast.error(t('settings.toast.categoryAtLeastOne'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -610,10 +612,10 @@ export const SettingsView: React.FC = () => {
|
||||
delete nextSettings[categoryToRemove];
|
||||
setCategorySettings(nextSettings);
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
||||
showToast.success(`Category "${categoryToRemove}" removed`);
|
||||
showToast.success(t('settings.toast.categoryRemoved', { category: categoryToRemove }));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove category:', error);
|
||||
showToast.error('Failed to remove category');
|
||||
showToast.error(t('settings.toast.categoryRemoveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -636,10 +638,10 @@ export const SettingsView: React.FC = () => {
|
||||
const defaults = { ...DEFAULT_CATEGORY_SETTINGS };
|
||||
setCategorySettings(defaults);
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: defaults });
|
||||
showToast.success('Categories reset to defaults');
|
||||
showToast.success(t('settings.toast.categoriesReset'));
|
||||
} catch (error) {
|
||||
console.error('Failed to reset categories:', error);
|
||||
showToast.error('Failed to reset categories');
|
||||
showToast.error(t('settings.toast.categoriesResetFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -662,14 +664,14 @@ export const SettingsView: React.FC = () => {
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
||||
} catch (error) {
|
||||
console.error('Failed to update category settings:', error);
|
||||
showToast.error('Failed to update category settings');
|
||||
showToast.error(t('settings.toast.categorySettingsUpdateFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const renderContentSettings = () => (
|
||||
<SettingSection
|
||||
id="settings-section-content"
|
||||
title="Post Categories"
|
||||
title={t('settings.content.title')}
|
||||
description="Manage the available categories for blog posts. Each post can have one category that determines its display template."
|
||||
hidden={!sectionHasMatches(contentKeywords)}
|
||||
>
|
||||
@@ -689,7 +691,7 @@ export const SettingsView: React.FC = () => {
|
||||
checked={setting.renderInLists}
|
||||
onChange={(event) => handleCategorySettingToggle(cat, 'renderInLists', event.target.checked)}
|
||||
/>
|
||||
<span>Render in lists</span>
|
||||
<span>{t('settings.content.renderInLists')}</span>
|
||||
</label>
|
||||
<label className="category-setting-toggle" htmlFor={`category-${cat}-show-title`}>
|
||||
<input
|
||||
@@ -699,7 +701,7 @@ export const SettingsView: React.FC = () => {
|
||||
checked={setting.showTitle}
|
||||
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
|
||||
/>
|
||||
<span>Show titles</span>
|
||||
<span>{t('settings.content.showTitles')}</span>
|
||||
</label>
|
||||
</div>
|
||||
{!isProtected && (
|
||||
@@ -748,13 +750,13 @@ export const SettingsView: React.FC = () => {
|
||||
const result = await window.electronAPI?.chat.setSystemPrompt(aiSystemPrompt);
|
||||
if (result?.success) {
|
||||
setAiSystemPromptModified(false);
|
||||
showToast.success('System prompt saved');
|
||||
showToast.success(t('settings.toast.systemPromptSaved'));
|
||||
} else {
|
||||
showToast.error('Failed to save system prompt');
|
||||
showToast.error(t('settings.toast.systemPromptSaveFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save system prompt:', error);
|
||||
showToast.error('Failed to save system prompt');
|
||||
showToast.error(t('settings.toast.systemPromptSaveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -766,11 +768,11 @@ export const SettingsView: React.FC = () => {
|
||||
if (result?.success) {
|
||||
setAiSystemPrompt(result.prompt || '');
|
||||
setAiSystemPromptModified(false);
|
||||
showToast.success('System prompt reset to default');
|
||||
showToast.success(t('settings.toast.systemPromptReset'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reset system prompt:', error);
|
||||
showToast.error('Failed to reset system prompt');
|
||||
showToast.error(t('settings.toast.systemPromptResetFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -783,13 +785,13 @@ export const SettingsView: React.FC = () => {
|
||||
setAiHasApiKey(true);
|
||||
setAiApiKeyMasked('•'.repeat(Math.max(0, newApiKey.length - 4)) + newApiKey.slice(-4));
|
||||
setNewApiKey('');
|
||||
showToast.success('API key saved and validated');
|
||||
showToast.success(t('settings.toast.apiKeySaved'));
|
||||
} else {
|
||||
showToast.error('Invalid API key');
|
||||
showToast.error(t('settings.toast.apiKeyInvalid'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save API key:', error);
|
||||
showToast.error('Failed to save API key');
|
||||
showToast.error(t('settings.toast.apiKeySaveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -798,18 +800,18 @@ export const SettingsView: React.FC = () => {
|
||||
const result = await window.electronAPI?.chat.setDefaultModel(modelId);
|
||||
if (result?.success) {
|
||||
setSelectedModel(modelId);
|
||||
showToast.success('Default model updated');
|
||||
showToast.success(t('settings.toast.defaultModelUpdated'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set model:', error);
|
||||
showToast.error('Failed to set default model');
|
||||
showToast.error(t('settings.toast.defaultModelUpdateFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const renderAISettings = () => (
|
||||
<SettingSection
|
||||
id="settings-section-ai"
|
||||
title="AI Assistant"
|
||||
title={t('settings.ai.title')}
|
||||
description="Configure the AI chat assistant that helps you manage your blog content."
|
||||
hidden={!sectionHasMatches(aiKeywords)}
|
||||
>
|
||||
@@ -865,7 +867,7 @@ export const SettingsView: React.FC = () => {
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
disabled={!aiHasApiKey}
|
||||
>
|
||||
{availableModels.length === 0 && <option value="">No models available</option>}
|
||||
{availableModels.length === 0 && <option value="">{t('settings.ai.noModels')}</option>}
|
||||
{availableModels.map(model => (
|
||||
<option key={model.id} value={model.id}>{model.name}</option>
|
||||
))}
|
||||
@@ -908,7 +910,7 @@ export const SettingsView: React.FC = () => {
|
||||
<>
|
||||
<SettingSection
|
||||
id="settings-section-publishing"
|
||||
title="FTP Publishing"
|
||||
title={t('settings.publishing.ftpTitle')}
|
||||
description="Configure FTP credentials for publishing your blog to a web server."
|
||||
hidden={!sectionHasMatches(publishingKeywords)}
|
||||
>
|
||||
@@ -964,13 +966,13 @@ export const SettingsView: React.FC = () => {
|
||||
</SettingRow>
|
||||
|
||||
<div className="setting-actions">
|
||||
<button className="primary" onClick={handleSavePublishing}>Save</button>
|
||||
<button className="secondary danger" onClick={() => handleClearCredentials('ftp')}>Clear</button>
|
||||
<button className="primary" onClick={handleSavePublishing}>{t('common.save')}</button>
|
||||
<button className="secondary danger" onClick={() => handleClearCredentials('ftp')}>{t('common.clear')}</button>
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title="SSH Publishing"
|
||||
title={t('settings.publishing.sshTitle')}
|
||||
description="Configure SSH credentials for secure deployment to your server."
|
||||
hidden={!sectionHasMatches(publishingKeywords)}
|
||||
>
|
||||
@@ -1017,8 +1019,8 @@ export const SettingsView: React.FC = () => {
|
||||
</SettingRow>
|
||||
|
||||
<div className="setting-actions">
|
||||
<button className="primary" onClick={handleSavePublishing}>Save</button>
|
||||
<button className="secondary danger" onClick={() => handleClearCredentials('ssh')}>Clear</button>
|
||||
<button className="primary" onClick={handleSavePublishing}>{t('common.save')}</button>
|
||||
<button className="secondary danger" onClick={() => handleClearCredentials('ssh')}>{t('common.clear')}</button>
|
||||
</div>
|
||||
</SettingSection>
|
||||
</>
|
||||
@@ -1028,7 +1030,7 @@ export const SettingsView: React.FC = () => {
|
||||
<>
|
||||
<SettingSection
|
||||
id="settings-section-data"
|
||||
title="Database Maintenance"
|
||||
title={t('settings.data.title')}
|
||||
description="Rebuild the local database index from source files. Useful if post or media files were edited externally."
|
||||
hidden={!sectionHasMatches(dataKeywords)}
|
||||
>
|
||||
@@ -1040,7 +1042,7 @@ export const SettingsView: React.FC = () => {
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={async () => {
|
||||
showToast.loading('Rebuilding posts database...');
|
||||
showToast.loading(t('settings.toast.rebuildPostsLoading'));
|
||||
try {
|
||||
await window.electronAPI?.posts.rebuildFromFiles();
|
||||
const postsResult = await window.electronAPI?.posts.getAll({ limit: 500, offset: 0 });
|
||||
@@ -1048,10 +1050,10 @@ export const SettingsView: React.FC = () => {
|
||||
useAppStore.getState().setPosts(postsResult.items, postsResult.hasMore, postsResult.total);
|
||||
}
|
||||
showToast.dismiss();
|
||||
showToast.success('Posts database rebuilt');
|
||||
showToast.success(t('settings.toast.rebuildPostsSuccess'));
|
||||
} catch {
|
||||
showToast.dismiss();
|
||||
showToast.error('Failed to rebuild posts database');
|
||||
showToast.error(t('settings.toast.rebuildPostsFailed'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1067,7 +1069,7 @@ export const SettingsView: React.FC = () => {
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={async () => {
|
||||
showToast.loading('Rebuilding media database...');
|
||||
showToast.loading(t('settings.toast.rebuildMediaLoading'));
|
||||
try {
|
||||
await window.electronAPI?.media.rebuildFromFiles();
|
||||
const media = await window.electronAPI?.media.getAll();
|
||||
@@ -1075,10 +1077,10 @@ export const SettingsView: React.FC = () => {
|
||||
useAppStore.getState().setMedia(media as any[]);
|
||||
}
|
||||
showToast.dismiss();
|
||||
showToast.success('Media database rebuilt');
|
||||
showToast.success(t('settings.toast.rebuildMediaSuccess'));
|
||||
} catch {
|
||||
showToast.dismiss();
|
||||
showToast.error('Failed to rebuild media database');
|
||||
showToast.error(t('settings.toast.rebuildMediaFailed'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1094,14 +1096,14 @@ export const SettingsView: React.FC = () => {
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={async () => {
|
||||
showToast.loading('Rebuilding post links...');
|
||||
showToast.loading(t('settings.toast.rebuildLinksLoading'));
|
||||
try {
|
||||
await window.electronAPI?.posts.rebuildLinks();
|
||||
showToast.dismiss();
|
||||
showToast.success('Post links rebuilt');
|
||||
showToast.success(t('settings.toast.rebuildLinksSuccess'));
|
||||
} catch {
|
||||
showToast.dismiss();
|
||||
showToast.error('Failed to rebuild post links');
|
||||
showToast.error(t('settings.toast.rebuildLinksFailed'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1117,20 +1119,20 @@ export const SettingsView: React.FC = () => {
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={async () => {
|
||||
showToast.loading('Generating thumbnails...');
|
||||
showToast.loading(t('settings.toast.thumbnailsLoading'));
|
||||
try {
|
||||
const result = await window.electronAPI?.media.regenerateMissingThumbnails();
|
||||
showToast.dismiss();
|
||||
if (result && result.generated > 0) {
|
||||
showToast.success(`Generated ${result.generated} thumbnails`);
|
||||
showToast.success(t('settings.toast.thumbnailsGenerated', { count: result.generated }));
|
||||
} else if (result && result.processed === 0) {
|
||||
showToast.success('All thumbnails already exist');
|
||||
showToast.success(t('settings.toast.thumbnailsAlreadyExist'));
|
||||
} else {
|
||||
showToast.success('Thumbnail generation complete');
|
||||
showToast.success(t('settings.toast.thumbnailsComplete'));
|
||||
}
|
||||
} catch {
|
||||
showToast.dismiss();
|
||||
showToast.error('Failed to generate thumbnails');
|
||||
showToast.error(t('settings.toast.thumbnailsFailed'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1140,7 +1142,7 @@ export const SettingsView: React.FC = () => {
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title="File System"
|
||||
title={t('settings.data.fileSystemTitle')}
|
||||
description="Access project data files and directories."
|
||||
hidden={!sectionHasMatches(dataKeywords)}
|
||||
>
|
||||
@@ -1178,12 +1180,12 @@ export const SettingsView: React.FC = () => {
|
||||
<div className="settings-view">
|
||||
{/* Header with search */}
|
||||
<div className="settings-header">
|
||||
<h2>Settings</h2>
|
||||
<h2>{t('common.settings')}</h2>
|
||||
<div className="settings-search">
|
||||
<span className="settings-search-icon"><SearchIcon /></span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search settings..."
|
||||
placeholder={t('settings.search.placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
@@ -1211,8 +1213,8 @@ export const SettingsView: React.FC = () => {
|
||||
</>
|
||||
) : (
|
||||
<div className="settings-no-results">
|
||||
<p>No settings found matching "{searchQuery}"</p>
|
||||
<button onClick={() => setSearchQuery('')}>Clear search</button>
|
||||
<p>{t('settings.search.noResults', { query: searchQuery })}</p>
|
||||
<button onClick={() => setSearchQuery('')}>{t('settings.search.clear')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useAppStore, Tab } from '../../store';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './TabBar.css';
|
||||
|
||||
const MAX_CHAT_TITLE_LENGTH = 18;
|
||||
@@ -22,7 +23,8 @@ const getTabTitle = (
|
||||
media: { id: string; originalName: string }[],
|
||||
chatTitles: Map<string, string>,
|
||||
importDefTitles: Map<string, string>,
|
||||
commitTitles: Map<string, string>
|
||||
commitTitles: Map<string, string>,
|
||||
tr: (key: string, vars?: Record<string, string | number>) => string,
|
||||
): string => {
|
||||
if (tab.type === 'git-diff') {
|
||||
const filePath = getGitDiffResource(tab.id);
|
||||
@@ -32,57 +34,57 @@ const getTabTitle = (
|
||||
if (commitTitle) {
|
||||
return commitTitle;
|
||||
}
|
||||
return `Commit ${commitHash.slice(0, 7)}`;
|
||||
return tr('tabBar.commitTitle', { hash: commitHash.slice(0, 7) });
|
||||
}
|
||||
const filename = filePath.split('/').pop();
|
||||
return filename || filePath;
|
||||
}
|
||||
|
||||
if (tab.type === 'settings') {
|
||||
return 'Settings';
|
||||
return tr('common.settings');
|
||||
}
|
||||
|
||||
if (tab.type === 'style') {
|
||||
return 'Style';
|
||||
return tr('tabBar.style');
|
||||
}
|
||||
|
||||
if (tab.type === 'tags') {
|
||||
return 'Tags';
|
||||
return tr('activity.tags');
|
||||
}
|
||||
|
||||
if (tab.type === 'post') {
|
||||
return postTitles.get(tab.id) || 'Loading...';
|
||||
return postTitles.get(tab.id) || tr('tabBar.loading');
|
||||
}
|
||||
|
||||
if (tab.type === 'media') {
|
||||
const mediaItem = media.find(m => m.id === tab.id);
|
||||
return mediaItem?.originalName || 'Media';
|
||||
return mediaItem?.originalName || tr('activity.media');
|
||||
}
|
||||
|
||||
if (tab.type === 'chat') {
|
||||
const title = chatTitles.get(tab.id);
|
||||
if (title && title !== 'New Chat') {
|
||||
if (title && title !== tr('chat.newChat')) {
|
||||
// Truncate long titles for display
|
||||
return title.length > MAX_CHAT_TITLE_LENGTH
|
||||
? title.substring(0, MAX_CHAT_TITLE_LENGTH) + '…'
|
||||
: title;
|
||||
}
|
||||
return 'New Chat';
|
||||
return tr('chat.newChat');
|
||||
}
|
||||
|
||||
if (tab.type === 'import') {
|
||||
return importDefTitles.get(tab.id) || 'Import';
|
||||
return importDefTitles.get(tab.id) || tr('activity.import');
|
||||
}
|
||||
|
||||
if (tab.type === 'metadata-diff') {
|
||||
return 'Metadata Diff';
|
||||
return tr('app.metadataDiff');
|
||||
}
|
||||
|
||||
if (tab.type === 'documentation') {
|
||||
return 'Documentation';
|
||||
return tr('docs.title');
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
return tr('tabBar.unknown');
|
||||
};
|
||||
|
||||
const getTabIcon = (tab: Tab): React.ReactNode => {
|
||||
@@ -176,6 +178,7 @@ const ChevronRightIcon: React.FC = () => (
|
||||
);
|
||||
|
||||
export const TabBar: React.FC = () => {
|
||||
const { t: tr } = useI18n();
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
@@ -218,7 +221,7 @@ export const TabBar: React.FC = () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = post.title || 'Untitled';
|
||||
const title = post.title || tr('editor.untitled');
|
||||
if (next.get(post.id) !== title) {
|
||||
next.set(post.id, title);
|
||||
changed = true;
|
||||
@@ -241,11 +244,11 @@ export const TabBar: React.FC = () => {
|
||||
try {
|
||||
const post = await window.electronAPI?.posts.get(tab.id);
|
||||
if (post) {
|
||||
newTitles.set(tab.id, post.title || 'Untitled');
|
||||
newTitles.set(tab.id, post.title || tr('editor.untitled'));
|
||||
changed = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch post title:', error);
|
||||
console.error(tr('tabBar.error.fetchPostTitle'), error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,7 +259,7 @@ export const TabBar: React.FC = () => {
|
||||
};
|
||||
|
||||
fetchTitles();
|
||||
}, [tabs, posts]); // Note: intentionally not including postTitles to avoid infinite loops
|
||||
}, [tabs, posts, tr]); // Note: intentionally not including postTitles to avoid infinite loops
|
||||
|
||||
// Listen for post updates to refresh titles
|
||||
useEffect(() => {
|
||||
@@ -265,7 +268,7 @@ export const TabBar: React.FC = () => {
|
||||
if (post) {
|
||||
setPostTitles(prev => {
|
||||
const newTitles = new Map(prev);
|
||||
newTitles.set(post.id, post.title || 'Untitled');
|
||||
newTitles.set(post.id, post.title || tr('editor.untitled'));
|
||||
return newTitles;
|
||||
});
|
||||
}
|
||||
@@ -274,7 +277,7 @@ export const TabBar: React.FC = () => {
|
||||
return () => {
|
||||
unsub?.();
|
||||
};
|
||||
}, []);
|
||||
}, [tr]);
|
||||
|
||||
// Fetch chat titles for chat tabs
|
||||
useEffect(() => {
|
||||
@@ -293,7 +296,7 @@ export const TabBar: React.FC = () => {
|
||||
newTitles.set(tab.id, conversation.title);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch chat title:', error);
|
||||
console.error(tr('tabBar.error.fetchChatTitle'), error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,7 +307,7 @@ export const TabBar: React.FC = () => {
|
||||
};
|
||||
|
||||
fetchTitles();
|
||||
}, [tabs]); // Note: intentionally not including chatTitles to avoid infinite loops
|
||||
}, [tabs, tr]); // Note: intentionally not including chatTitles to avoid infinite loops
|
||||
|
||||
// Listen for chat title updates
|
||||
useEffect(() => {
|
||||
@@ -336,7 +339,7 @@ export const TabBar: React.FC = () => {
|
||||
newTitles.set(tab.id, def.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch import definition title:', error);
|
||||
console.error(tr('tabBar.error.fetchImportTitle'), error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,7 +349,7 @@ export const TabBar: React.FC = () => {
|
||||
};
|
||||
|
||||
fetchTitles();
|
||||
}, [tabs]); // Note: intentionally not including importDefTitles to avoid infinite loops
|
||||
}, [tabs, tr]); // Note: intentionally not including importDefTitles to avoid infinite loops
|
||||
|
||||
// Listen for import definition name updates
|
||||
useEffect(() => {
|
||||
@@ -411,7 +414,7 @@ export const TabBar: React.FC = () => {
|
||||
return changed ? updated : previous;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch commit titles:', error);
|
||||
console.error(tr('tabBar.error.fetchCommitTitle'), error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -420,7 +423,7 @@ export const TabBar: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tabs, activeProject]);
|
||||
}, [tabs, activeProject, tr]);
|
||||
|
||||
// Check if arrows are needed based on scroll position
|
||||
const updateArrowVisibility = useCallback(() => {
|
||||
@@ -534,7 +537,7 @@ export const TabBar: React.FC = () => {
|
||||
<button
|
||||
className="tab-scroll-button tab-scroll-left"
|
||||
onClick={scrollLeft}
|
||||
title="Scroll tabs left"
|
||||
title={tr('tabBar.scrollLeft')}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
@@ -544,7 +547,7 @@ export const TabBar: React.FC = () => {
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id);
|
||||
const title = getTabTitle(tab, postTitles, media, chatTitles, importDefTitles, commitTitles);
|
||||
const title = getTabTitle(tab, postTitles, media, chatTitles, importDefTitles, commitTitles, tr);
|
||||
const icon = getTabIcon(tab);
|
||||
|
||||
return (
|
||||
@@ -555,7 +558,7 @@ export const TabBar: React.FC = () => {
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
onDoubleClick={() => handleTabDoubleClick(tab)}
|
||||
onMouseDown={(e) => handleMiddleClick(e, tab.id)}
|
||||
title={`${title}${tab.isTransient ? ' (Preview)' : ''}${isDirty ? ' • Modified' : ''}`}
|
||||
title={`${title}${tab.isTransient ? ` (${tr('tabBar.preview')})` : ''}${isDirty ? ` • ${tr('tabBar.modified')}` : ''}`}
|
||||
>
|
||||
<span className="tab-icon">{icon}</span>
|
||||
<span className={`tab-title ${tab.isTransient ? 'italic' : ''}`}>
|
||||
@@ -566,7 +569,7 @@ export const TabBar: React.FC = () => {
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleTabClose(e, tab.id)}
|
||||
title="Close (Ctrl+W)"
|
||||
title={tr('tabBar.closeHint')}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
@@ -580,7 +583,7 @@ export const TabBar: React.FC = () => {
|
||||
<button
|
||||
className="tab-scroll-button tab-scroll-right"
|
||||
onClick={scrollRight}
|
||||
title="Scroll tabs right"
|
||||
title={tr('tabBar.scrollRight')}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import type { TaskProgress } from '../../../main/shared/electronApi';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './TaskPopup.css';
|
||||
|
||||
interface GroupedTaskEntry {
|
||||
@@ -70,6 +71,7 @@ function buildTaskEntries(tasks: TaskProgress[]): TaskEntry[] {
|
||||
}
|
||||
|
||||
export const TaskPopup: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { tasks } = useAppStore();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
@@ -174,7 +176,7 @@ export const TaskPopup: React.FC = () => {
|
||||
<button
|
||||
className="task-cancel"
|
||||
onClick={() => handleCancel(task.taskId)}
|
||||
title="Cancel task"
|
||||
title={t('tasks.cancelTask')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -216,57 +218,57 @@ export const TaskPopup: React.FC = () => {
|
||||
<button
|
||||
className={`task-popup-trigger ${hasActiveTasks ? 'active' : ''}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title={`${runningTasks.length} running, ${pendingTasks.length} pending`}
|
||||
title={t('tasks.triggerTitle', { running: runningTasks.length, pending: pendingTasks.length })}
|
||||
>
|
||||
{runningTasks.length > 0 ? (
|
||||
<>
|
||||
<span className="task-spinner" />
|
||||
<span>{runningTasks.length} running</span>
|
||||
<span>{`${runningTasks.length} ${t('common.running')}`}</span>
|
||||
</>
|
||||
) : pendingTasks.length > 0 ? (
|
||||
<>
|
||||
<span className="task-icon pending">○</span>
|
||||
<span>{pendingTasks.length} pending</span>
|
||||
<span>{`${pendingTasks.length} ${t('common.pending')}`}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Tasks</span>
|
||||
<span>{t('common.tasks')}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="task-popup">
|
||||
<div className="task-popup-header">
|
||||
<h4>Background Tasks</h4>
|
||||
<h4>{t('tasks.backgroundTasks')}</h4>
|
||||
{recentTasks.length > 0 && (
|
||||
<button className="text-button" onClick={handleClearCompleted}>
|
||||
Clear completed
|
||||
{t('tasks.clearCompleted')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{runningTasks.length > 0 && (
|
||||
<div className="task-section">
|
||||
<div className="task-section-title">Running</div>
|
||||
<div className="task-section-title">{t('common.running')}</div>
|
||||
{renderEntries(runningEntries)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingTasks.length > 0 && (
|
||||
<div className="task-section">
|
||||
<div className="task-section-title">Pending</div>
|
||||
<div className="task-section-title">{t('common.pending')}</div>
|
||||
{renderEntries(pendingEntries)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recentTasks.length > 0 && (
|
||||
<div className="task-section">
|
||||
<div className="task-section-title">Recent</div>
|
||||
<div className="task-section-title">{t('tasks.recent')}</div>
|
||||
{renderEntries(recentEntries)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runningTasks.length === 0 && pendingTasks.length === 0 && recentTasks.length === 0 && (
|
||||
<div className="task-empty">No active tasks</div>
|
||||
<div className="task-empty">{t('tasks.noActive')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands';
|
||||
import { resolveSupportedUiLanguage, translateMenu } from '../../../main/shared/i18n';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './WindowTitleBar.css';
|
||||
|
||||
type WindowControlsOverlayLike = {
|
||||
@@ -11,6 +13,7 @@ type WindowControlsOverlayLike = {
|
||||
};
|
||||
|
||||
export const WindowTitleBar: React.FC = () => {
|
||||
const { language } = 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);
|
||||
@@ -33,6 +36,19 @@ export const WindowTitleBar: React.FC = () => {
|
||||
};
|
||||
});
|
||||
|
||||
const uiLanguage = resolveSupportedUiLanguage(language);
|
||||
|
||||
const getGroupDisplayLabel = (groupLabel: string): string => {
|
||||
return translateMenu(uiLanguage, `menu.group.${groupLabel.toLowerCase()}`) || groupLabel;
|
||||
};
|
||||
|
||||
const getItemDisplayLabel = (itemLabel: string): string => {
|
||||
if (itemLabel.startsWith('menu.')) {
|
||||
return translateMenu(uiLanguage, itemLabel) || itemLabel;
|
||||
}
|
||||
return itemLabel;
|
||||
};
|
||||
|
||||
const mnemonicByKey = useMemo(() => {
|
||||
return visibleMenuGroups.reduce<Record<string, string>>((acc, group) => {
|
||||
const mnemonicKey = group.label.charAt(0).toLowerCase();
|
||||
@@ -247,7 +263,7 @@ export const WindowTitleBar: React.FC = () => {
|
||||
const typed = event.key.toLowerCase();
|
||||
const matchingIndices = actionableItems
|
||||
.map((item, index) => ({ item, index }))
|
||||
.filter(entry => entry.item.label.toLowerCase().startsWith(typed))
|
||||
.filter(entry => getItemDisplayLabel(entry.item.label).toLowerCase().startsWith(typed))
|
||||
.map(entry => entry.index);
|
||||
|
||||
if (matchingIndices.length === 0) {
|
||||
@@ -402,7 +418,7 @@ export const WindowTitleBar: React.FC = () => {
|
||||
onMouseEnter={(event) => handleMenuButtonMouseEnter(event, group.label)}
|
||||
aria-label={group.label}
|
||||
>
|
||||
{renderMenuLabel(group.label)}
|
||||
{renderMenuLabel(getGroupDisplayLabel(group.label))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -452,6 +468,7 @@ export const WindowTitleBar: React.FC = () => {
|
||||
return <div key={item.action} className="window-titlebar-menu-separator" />;
|
||||
}
|
||||
|
||||
const displayLabel = getItemDisplayLabel(item.label);
|
||||
const acceleratorText = item.accelerator ? formatAccelerator(item.accelerator) : null;
|
||||
const actionableItems = activeMenu.items.filter(menuItem => !menuItem.separator);
|
||||
const currentActionableIndex = actionableItems.findIndex(menuItem => menuItem.action === item.action);
|
||||
@@ -463,9 +480,9 @@ export const WindowTitleBar: React.FC = () => {
|
||||
type="button"
|
||||
className={`window-titlebar-menu-item${isKeyboardActive ? ' is-keyboard-active' : ''}`}
|
||||
onClick={() => handleMenuItemClick(item.action)}
|
||||
aria-label={acceleratorText ? `${item.label} ${acceleratorText}` : item.label}
|
||||
aria-label={acceleratorText ? `${displayLabel} ${acceleratorText}` : displayLabel}
|
||||
>
|
||||
<span className="window-titlebar-menu-item-label">{item.label}</span>
|
||||
<span className="window-titlebar-menu-item-label">{displayLabel}</span>
|
||||
{acceleratorText && <span className="window-titlebar-menu-item-accelerator">{acceleratorText}</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user