feat: i18n support with first translations

This commit is contained in:
2026-02-21 10:45:41 +01:00
parent a5281a7750
commit b8005bec30
48 changed files with 2792 additions and 462 deletions

View File

@@ -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>
)}
</>

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>
);