chore: lots of i18n
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAppStore, MediaData } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './LinkedMediaPanel.css';
|
||||
|
||||
/** Get display name for media: title (truncated to 60 chars) or fallback to filename */
|
||||
@@ -35,6 +36,7 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [linkedMedia, setLinkedMedia] = useState<MediaData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
@@ -118,13 +120,13 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
||||
await window.electronAPI?.postMedia.link(postId, media.id);
|
||||
}
|
||||
|
||||
showToast.success(`Imported and linked ${imported.length} file(s)`);
|
||||
showToast.success(t('linkedMediaPanel.toast.importedLinked', { count: imported.length }));
|
||||
|
||||
// Refresh the linked media list
|
||||
loadLinkedMedia();
|
||||
} catch (error) {
|
||||
console.error('Failed to import media:', error);
|
||||
showToast.error('Failed to import media');
|
||||
showToast.error(t('linkedMediaPanel.toast.importFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -132,11 +134,11 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
||||
const handleUnlink = async (mediaId: string) => {
|
||||
try {
|
||||
await window.electronAPI?.postMedia.unlink(postId, mediaId);
|
||||
showToast.success('Media unlinked from post');
|
||||
showToast.success(t('linkedMediaPanel.toast.unlinked'));
|
||||
loadLinkedMedia();
|
||||
} catch (error) {
|
||||
console.error('Failed to unlink media:', error);
|
||||
showToast.error('Failed to unlink media');
|
||||
showToast.error(t('linkedMediaPanel.toast.unlinkFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,13 +146,13 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
||||
const handleLinkExisting = async (mediaId: string) => {
|
||||
try {
|
||||
await window.electronAPI?.postMedia.link(postId, mediaId);
|
||||
showToast.success('Media linked to post');
|
||||
showToast.success(t('linkedMediaPanel.toast.linked'));
|
||||
setShowMediaPicker(false);
|
||||
setMediaSearchQuery('');
|
||||
loadLinkedMedia();
|
||||
} catch (error) {
|
||||
console.error('Failed to link media:', error);
|
||||
showToast.error('Failed to link media');
|
||||
showToast.error(t('linkedMediaPanel.toast.linkFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -223,7 +225,7 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
||||
<div className="linked-media-panel collapsed" onClick={onToggleCollapse}>
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">
|
||||
📷 Media ({linkedMedia.length})
|
||||
{t('linkedMediaPanel.collapsedTitle', { count: linkedMedia.length })}
|
||||
</span>
|
||||
<span className="expand-icon">▶</span>
|
||||
</div>
|
||||
@@ -234,19 +236,19 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
||||
return (
|
||||
<div className="linked-media-panel">
|
||||
<div className="panel-header" onClick={onToggleCollapse}>
|
||||
<span className="panel-title">📷 Linked Media</span>
|
||||
<span className="panel-title">{t('linkedMediaPanel.title')}</span>
|
||||
<div className="panel-actions">
|
||||
<button
|
||||
className="panel-action"
|
||||
onClick={(e) => { e.stopPropagation(); handleImportMedia(); }}
|
||||
title="Import and link media"
|
||||
title={t('linkedMediaPanel.importAndLink')}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
className="panel-action"
|
||||
onClick={(e) => { e.stopPropagation(); setShowMediaPicker(!showMediaPicker); }}
|
||||
title="Link existing media"
|
||||
title={t('linkedMediaPanel.linkExisting')}
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
@@ -257,13 +259,13 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
||||
{showMediaPicker && (
|
||||
<div className="media-picker">
|
||||
<div className="media-picker-header">
|
||||
<span>Select media to link</span>
|
||||
<span>{t('linkedMediaPanel.selectMediaToLink')}</span>
|
||||
<button onClick={() => { setShowMediaPicker(false); setMediaSearchQuery(''); }}>×</button>
|
||||
</div>
|
||||
<div className="media-picker-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search media..."
|
||||
placeholder={t('linkedMediaPanel.searchPlaceholder')}
|
||||
value={mediaSearchQuery}
|
||||
onChange={(e) => setMediaSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
@@ -271,7 +273,7 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
||||
</div>
|
||||
<div className="media-picker-grid">
|
||||
{unlinkedMedia.length === 0 ? (
|
||||
<div className="no-media">No unlinked media available</div>
|
||||
<div className="no-media">{t('linkedMediaPanel.noUnlinkedMedia')}</div>
|
||||
) : (
|
||||
unlinkedMedia.map(media => (
|
||||
<div
|
||||
@@ -295,11 +297,11 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
||||
|
||||
<div className="panel-content">
|
||||
{isLoading ? (
|
||||
<div className="loading">Loading...</div>
|
||||
<div className="loading">{t('gitSidebar.loading')}</div>
|
||||
) : linkedMedia.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No media linked to this post</p>
|
||||
<button onClick={handleImportMedia}>Import Media</button>
|
||||
<p>{t('linkedMediaPanel.noMediaLinked')}</p>
|
||||
<button onClick={handleImportMedia}>{t('linkedMediaPanel.importMedia')}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="media-list">
|
||||
@@ -330,7 +332,7 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
||||
<button
|
||||
className="unlink-btn"
|
||||
onClick={(e) => { e.stopPropagation(); handleUnlink(media.id); }}
|
||||
title="Unlink from post"
|
||||
title={t('linkedMediaPanel.unlinkFromPost')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import type { TaskProgress } from '../../../main/shared/electronApi';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './Panel.css';
|
||||
|
||||
function getPostRelativePath(createdAt: string, slug: string): string | null {
|
||||
@@ -101,6 +102,7 @@ function buildTaskEntries(tasks: TaskProgress[]): TaskEntry[] {
|
||||
}
|
||||
|
||||
export const Panel: React.FC = () => {
|
||||
const { t, language } = useI18n();
|
||||
const {
|
||||
panelVisible,
|
||||
panelActiveTab,
|
||||
@@ -191,7 +193,7 @@ export const Panel: React.FC = () => {
|
||||
|
||||
setPostLinksEntries([...fromEntries, ...toEntries]);
|
||||
} catch (error) {
|
||||
setPostLinksError(error instanceof Error ? error.message : 'Failed to load post links.');
|
||||
setPostLinksError(error instanceof Error ? error.message : t('panel.error.loadPostLinks'));
|
||||
setPostLinksEntries([]);
|
||||
} finally {
|
||||
setPostLinksLoading(false);
|
||||
@@ -277,7 +279,7 @@ export const Panel: React.FC = () => {
|
||||
if (requestIdRef.current !== currentRequestId) {
|
||||
return;
|
||||
}
|
||||
setGitLogError(error instanceof Error ? error.message : 'Failed to load git log.');
|
||||
setGitLogError(error instanceof Error ? error.message : t('panel.error.loadGitLog'));
|
||||
setGitLogEntries([]);
|
||||
} finally {
|
||||
if (requestIdRef.current === currentRequestId) {
|
||||
@@ -339,7 +341,7 @@ export const Panel: React.FC = () => {
|
||||
className="task-cancel"
|
||||
onClick={() => window.electronAPI?.tasks.cancel(task.taskId)}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -348,7 +350,7 @@ export const Panel: React.FC = () => {
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-header">
|
||||
<div className="panel-tabs" role="tablist" aria-label="Panel tabs">
|
||||
<div className="panel-tabs" role="tablist" aria-label={t('panel.tabsAria')}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -356,7 +358,7 @@ export const Panel: React.FC = () => {
|
||||
aria-selected={effectiveActivePanelTab === 'tasks'}
|
||||
onClick={() => setPanelActiveTab('tasks')}
|
||||
>
|
||||
Tasks
|
||||
{t('common.tasks')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -365,7 +367,7 @@ export const Panel: React.FC = () => {
|
||||
aria-selected={effectiveActivePanelTab === 'output'}
|
||||
onClick={() => setPanelActiveTab('output')}
|
||||
>
|
||||
Output
|
||||
{t('panel.output')}
|
||||
</button>
|
||||
{canActivatePostLinks && (
|
||||
<button
|
||||
@@ -375,7 +377,7 @@ export const Panel: React.FC = () => {
|
||||
aria-selected={effectiveActivePanelTab === 'post-links'}
|
||||
onClick={() => setPanelActiveTab('post-links')}
|
||||
>
|
||||
Post Links
|
||||
{t('panel.postLinks')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -390,13 +392,13 @@ export const Panel: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Git Log
|
||||
{t('panel.gitLog')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="panel-close"
|
||||
onClick={() => useAppStore.getState().togglePanel()}
|
||||
title="Close Panel"
|
||||
title={t('panel.closeTitle')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -404,7 +406,7 @@ export const Panel: React.FC = () => {
|
||||
<div className="panel-content">
|
||||
{effectiveActivePanelTab === 'tasks' && (
|
||||
recentTasks.length === 0 ? (
|
||||
<div className="panel-empty">No recent tasks</div>
|
||||
<div className="panel-empty">{t('panel.noRecentTasks')}</div>
|
||||
) : (
|
||||
<div className="task-list">
|
||||
{recentTaskEntries.map((entry) => {
|
||||
@@ -434,18 +436,18 @@ export const Panel: React.FC = () => {
|
||||
)}
|
||||
|
||||
{effectiveActivePanelTab === 'output' && (
|
||||
<div className="panel-empty">No output</div>
|
||||
<div className="panel-empty">{t('panel.noOutput')}</div>
|
||||
)}
|
||||
|
||||
{effectiveActivePanelTab === 'post-links' && (
|
||||
!canActivatePostLinks ? (
|
||||
<div className="panel-empty">Open a post editor to view post links</div>
|
||||
<div className="panel-empty">{t('panel.openPostEditor')}</div>
|
||||
) : postLinksLoading ? (
|
||||
<div className="panel-empty">Loading post links...</div>
|
||||
<div className="panel-empty">{t('panel.loadingPostLinks')}</div>
|
||||
) : postLinksError ? (
|
||||
<div className="panel-empty">{postLinksError}</div>
|
||||
) : postLinksEntries.length === 0 ? (
|
||||
<div className="panel-empty">No post links for this post</div>
|
||||
<div className="panel-empty">{t('panel.noPostLinks')}</div>
|
||||
) : (
|
||||
<div className="post-links-list">
|
||||
{postLinksEntries.map((entry) => (
|
||||
@@ -454,9 +456,9 @@ export const Panel: React.FC = () => {
|
||||
type="button"
|
||||
className="post-links-item"
|
||||
onClick={() => handlePostLinkClick(entry.id)}
|
||||
title={`Open ${entry.title || entry.slug}`}
|
||||
title={t('postLinks.openTitle', { title: entry.title || entry.slug })}
|
||||
>
|
||||
<span className="post-links-direction">{entry.direction} {entry.slug}</span>
|
||||
<span className="post-links-direction">{t(`panel.direction.${entry.direction}`)} {entry.slug}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -465,13 +467,13 @@ export const Panel: React.FC = () => {
|
||||
|
||||
{effectiveActivePanelTab === 'git-log' && (
|
||||
!canActivateGitLog ? (
|
||||
<div className="panel-empty">Open a post or media editor to view git log</div>
|
||||
<div className="panel-empty">{t('panel.openPostOrMediaEditor')}</div>
|
||||
) : gitLogLoading ? (
|
||||
<div className="panel-empty">Loading git log...</div>
|
||||
<div className="panel-empty">{t('panel.loadingGitLog')}</div>
|
||||
) : gitLogError ? (
|
||||
<div className="panel-empty">{gitLogError}</div>
|
||||
) : gitLogEntries.length === 0 ? (
|
||||
<div className="panel-empty">No commits found for this item</div>
|
||||
<div className="panel-empty">{t('panel.noCommits')}</div>
|
||||
) : (
|
||||
<div className="git-log-list">
|
||||
<div className="git-log-target">{gitLogTargetLabel}</div>
|
||||
@@ -481,7 +483,7 @@ export const Panel: React.FC = () => {
|
||||
<div className="git-log-meta">
|
||||
<span className="git-log-hash">{entry.shortHash}</span>
|
||||
<span>{entry.author}</span>
|
||||
<span>{new Date(entry.date).toLocaleString()}</span>
|
||||
<span>{new Date(entry.date).toLocaleString(language)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -2,9 +2,11 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useAppStore, ProjectData, PostData, MediaData } from '../../store';
|
||||
import { loadTabsForProject, saveTabsForProject } from '../../utils';
|
||||
import { showToast } from '../Toast';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './ProjectSelector.css';
|
||||
|
||||
export const ProjectSelector: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { projects, activeProject, setProjects, setActiveProject, setPosts, setMedia, setSelectedPost, setSelectedMedia, removeProject, getTabState, restoreTabState, clearTabs } = useAppStore();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
@@ -83,11 +85,11 @@ export const ProjectSelector: React.FC = () => {
|
||||
restoreTabState(savedTabState);
|
||||
}
|
||||
|
||||
showToast.success(`Switched to ${project.name}`);
|
||||
showToast.success(t('projectSelector.toast.switched', { name: project.name }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to switch project:', error);
|
||||
showToast.error('Failed to switch project');
|
||||
showToast.error(t('projectSelector.toast.switchFailed'));
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
@@ -104,7 +106,7 @@ export const ProjectSelector: React.FC = () => {
|
||||
});
|
||||
if (newProject) {
|
||||
setProjects([...projects, newProject as ProjectData]);
|
||||
showToast.success(`Created project "${newProjectName}"`);
|
||||
showToast.success(t('projectSelector.toast.created', { name: newProjectName }));
|
||||
setNewProjectName('');
|
||||
setNewProjectDescription('');
|
||||
setNewProjectDataPath(null);
|
||||
@@ -115,13 +117,13 @@ export const ProjectSelector: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error);
|
||||
showToast.error('Failed to create project');
|
||||
showToast.error(t('projectSelector.toast.createFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFolder = async () => {
|
||||
try {
|
||||
const selectedPath = await window.electronAPI?.app.selectFolder('Select Project Location');
|
||||
const selectedPath = await window.electronAPI?.app.selectFolder(t('projectSelector.selectProjectLocation'));
|
||||
if (selectedPath) {
|
||||
setNewProjectDataPath(selectedPath);
|
||||
|
||||
@@ -135,12 +137,12 @@ export const ProjectSelector: React.FC = () => {
|
||||
if (existingMetadata.description) {
|
||||
setNewProjectDescription(existingMetadata.description);
|
||||
}
|
||||
showToast.info('Found existing project settings');
|
||||
showToast.info(t('projectSelector.toast.existingSettingsFound'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select folder:', error);
|
||||
showToast.error('Failed to select folder');
|
||||
showToast.error(t('projectSelector.toast.selectFolderFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -171,16 +173,16 @@ export const ProjectSelector: React.FC = () => {
|
||||
const success = await window.electronAPI?.projects.deleteWithData(projectToDelete.id);
|
||||
if (success) {
|
||||
removeProject(projectToDelete.id);
|
||||
showToast.success(`Deleted project "${projectToDelete.name}" and all its data`);
|
||||
showToast.success(t('projectSelector.toast.deletedWithData', { name: projectToDelete.name }));
|
||||
setShowDeleteModal(false);
|
||||
setProjectToDelete(null);
|
||||
setDeleteConfirmText('');
|
||||
} else {
|
||||
showToast.error('Failed to delete project');
|
||||
showToast.error(t('projectSelector.toast.deleteFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete project:', error);
|
||||
showToast.error('Failed to delete project');
|
||||
showToast.error(t('projectSelector.toast.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,12 +196,12 @@ export const ProjectSelector: React.FC = () => {
|
||||
<button
|
||||
className="project-selector-trigger"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title="Switch project"
|
||||
title={t('projectSelector.switchProject')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="project-icon">
|
||||
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"/>
|
||||
</svg>
|
||||
<span className="project-name">{activeProject?.name || 'Select Project'}</span>
|
||||
<span className="project-name">{activeProject?.name || t('projectSelector.selectProject')}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="dropdown-arrow">
|
||||
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"/>
|
||||
</svg>
|
||||
@@ -208,7 +210,7 @@ export const ProjectSelector: React.FC = () => {
|
||||
{isOpen && (
|
||||
<div className="project-dropdown">
|
||||
<div className="project-dropdown-header">
|
||||
<span>PROJECTS</span>
|
||||
<span>{t('projectSelector.projectsHeader')}</span>
|
||||
</div>
|
||||
<div className="project-list">
|
||||
{projects.map(project => (
|
||||
@@ -231,7 +233,7 @@ export const ProjectSelector: React.FC = () => {
|
||||
<button
|
||||
className="project-item-delete"
|
||||
onClick={(e) => openDeleteModal(e, project)}
|
||||
title={`Delete ${project.name}`}
|
||||
title={t('projectSelector.deleteProjectTitle', { name: project.name })}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"/>
|
||||
@@ -242,7 +244,7 @@ export const ProjectSelector: React.FC = () => {
|
||||
</div>
|
||||
))}
|
||||
{projects.length === 0 && (
|
||||
<div className="project-empty">No projects yet</div>
|
||||
<div className="project-empty">{t('projectSelector.noProjectsYet')}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="project-dropdown-footer">
|
||||
@@ -250,7 +252,7 @@ export const ProjectSelector: React.FC = () => {
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
|
||||
</svg>
|
||||
New Project
|
||||
{t('projectSelector.newProject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -260,7 +262,7 @@ export const ProjectSelector: React.FC = () => {
|
||||
<div className="modal-overlay" onClick={handleCloseCreateModal}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Create New Project</h3>
|
||||
<h3>{t('projectSelector.createNewProject')}</h3>
|
||||
<button className="modal-close" onClick={handleCloseCreateModal}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
|
||||
@@ -270,33 +272,33 @@ export const ProjectSelector: React.FC = () => {
|
||||
<form onSubmit={handleCreateProject}>
|
||||
<div className="modal-body">
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-name">Project Name</label>
|
||||
<label htmlFor="project-name">{t('projectSelector.projectName')}</label>
|
||||
<input
|
||||
id="project-name"
|
||||
type="text"
|
||||
value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
placeholder="My Blog"
|
||||
placeholder={t('projectSelector.projectNamePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-description">Description (optional)</label>
|
||||
<label htmlFor="project-description">{t('projectSelector.descriptionOptional')}</label>
|
||||
<textarea
|
||||
id="project-description"
|
||||
value={newProjectDescription}
|
||||
onChange={e => setNewProjectDescription(e.target.value)}
|
||||
placeholder="A brief description of this project..."
|
||||
placeholder={t('projectSelector.descriptionPlaceholder')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Project Location</label>
|
||||
<label>{t('projectSelector.projectLocation')}</label>
|
||||
<div className="folder-picker">
|
||||
{newProjectDataPath ? (
|
||||
<div className="folder-path-display">
|
||||
<span className="folder-path" title={newProjectDataPath}>{newProjectDataPath}</span>
|
||||
<button type="button" className="btn-icon" onClick={handleClearFolder} title="Use default location">
|
||||
<button type="button" className="btn-icon" onClick={handleClearFolder} title={t('projectSelector.useDefaultLocation')}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
|
||||
</svg>
|
||||
@@ -304,27 +306,27 @@ export const ProjectSelector: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="folder-default-info">
|
||||
<span className="default-label">Default (internal storage)</span>
|
||||
<span className="default-label">{t('projectSelector.defaultInternalStorage')}</span>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn-secondary btn-small" onClick={handleSelectFolder}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"/>
|
||||
</svg>
|
||||
Choose Folder...
|
||||
{t('projectSelector.chooseFolder')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-hint">
|
||||
Choose a custom folder for cloud storage backup, or use the default internal storage.
|
||||
{t('projectSelector.projectLocationHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-secondary" onClick={handleCloseCreateModal}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={!newProjectName.trim()}>
|
||||
Create Project
|
||||
{t('projectSelector.createProject')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -336,7 +338,7 @@ export const ProjectSelector: React.FC = () => {
|
||||
<div className="modal-overlay" onClick={() => setShowDeleteModal(false)}>
|
||||
<div className="modal-content modal-danger" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Delete Project</h3>
|
||||
<h3>{t('projectSelector.deleteProject')}</h3>
|
||||
<button className="modal-close" onClick={() => setShowDeleteModal(false)}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
|
||||
@@ -346,15 +348,15 @@ export const ProjectSelector: React.FC = () => {
|
||||
<form onSubmit={handleDeleteProject}>
|
||||
<div className="modal-body">
|
||||
<p className="delete-warning">
|
||||
This will permanently delete the project <strong>"{projectToDelete.name}"</strong> and all its data including:
|
||||
{t('projectSelector.deleteWarning', { name: projectToDelete.name })}
|
||||
</p>
|
||||
<ul className="delete-list">
|
||||
<li>All blog posts</li>
|
||||
<li>All media files</li>
|
||||
<li>All project settings</li>
|
||||
<li>{t('projectSelector.deleteItemPosts')}</li>
|
||||
<li>{t('projectSelector.deleteItemMedia')}</li>
|
||||
<li>{t('projectSelector.deleteItemSettings')}</li>
|
||||
</ul>
|
||||
<p className="delete-confirm-text">
|
||||
Type <strong>{projectToDelete.name}</strong> to confirm deletion:
|
||||
{t('projectSelector.typeToConfirm', { name: projectToDelete.name })}
|
||||
</p>
|
||||
<div className="form-field">
|
||||
<input
|
||||
@@ -368,14 +370,14 @@ export const ProjectSelector: React.FC = () => {
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-secondary" onClick={() => setShowDeleteModal(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-danger"
|
||||
disabled={deleteConfirmText !== projectToDelete.name}
|
||||
>
|
||||
Delete Project
|
||||
{t('projectSelector.deleteProject')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,11 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { useI18n } from '../../i18n';
|
||||
import {
|
||||
resolveSupportedRenderLanguage,
|
||||
SUPPORTED_RENDER_LANGUAGES,
|
||||
type SupportedLanguage,
|
||||
} from '../../../main/shared/i18n';
|
||||
import './SettingsView.css';
|
||||
|
||||
// Export category IDs for sidebar navigation
|
||||
@@ -33,6 +38,14 @@ interface CategoryRenderSettings {
|
||||
showTitle: boolean;
|
||||
}
|
||||
|
||||
const RENDER_LANGUAGE_LABEL_KEY: Record<SupportedLanguage, string> = {
|
||||
en: 'settings.language.english',
|
||||
de: 'settings.language.german',
|
||||
fr: 'settings.language.french',
|
||||
it: 'settings.language.italian',
|
||||
es: 'settings.language.spanish',
|
||||
};
|
||||
|
||||
const defaultCredentials: Credentials = {
|
||||
ftpHost: '',
|
||||
ftpUser: '',
|
||||
@@ -123,7 +136,7 @@ export const SettingsView: React.FC = () => {
|
||||
const [projectDataPath, setProjectDataPath] = useState('');
|
||||
const [projectPublicUrl, setProjectPublicUrl] = useState('');
|
||||
const [defaultProjectPath, setDefaultProjectPath] = useState('');
|
||||
const [projectMainLanguage, setProjectMainLanguage] = useState('en');
|
||||
const [projectMainLanguage, setProjectMainLanguage] = useState<SupportedLanguage>('en');
|
||||
const [projectDefaultAuthor, setProjectDefaultAuthor] = useState('');
|
||||
const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50);
|
||||
|
||||
@@ -169,7 +182,7 @@ export const SettingsView: React.FC = () => {
|
||||
setProjectPublicUrl('');
|
||||
}
|
||||
if (metadata?.mainLanguage) {
|
||||
setProjectMainLanguage(metadata.mainLanguage);
|
||||
setProjectMainLanguage(resolveSupportedRenderLanguage(metadata.mainLanguage));
|
||||
}
|
||||
if (metadata?.defaultAuthor) {
|
||||
setProjectDefaultAuthor(metadata.defaultAuthor);
|
||||
@@ -302,7 +315,7 @@ export const SettingsView: React.FC = () => {
|
||||
description: projectDescription.trim(),
|
||||
dataPath: projectDataPath.trim() || undefined,
|
||||
publicUrl: projectPublicUrl.trim() || undefined,
|
||||
mainLanguage: projectMainLanguage,
|
||||
mainLanguage: resolveSupportedRenderLanguage(projectMainLanguage),
|
||||
defaultAuthor: projectDefaultAuthor.trim() || undefined,
|
||||
maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))),
|
||||
categorySettings,
|
||||
@@ -415,28 +428,11 @@ export const SettingsView: React.FC = () => {
|
||||
<select
|
||||
id="project-language"
|
||||
value={projectMainLanguage}
|
||||
onChange={(e) => setProjectMainLanguage(e.target.value)}
|
||||
onChange={(e) => setProjectMainLanguage(resolveSupportedRenderLanguage(e.target.value))}
|
||||
>
|
||||
<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>
|
||||
{SUPPORTED_RENDER_LANGUAGES.map((language) => (
|
||||
<option key={language} value={language}>{t(RENDER_LANGUAGE_LABEL_KEY[language])}</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { PICO_THEME_NAMES, PICO_THEME_PREVIEW_TOKENS, getRendererPicoTheme, ensureRendererPicoThemeStylesheet, type PicoThemeName } from '../../utils/picoTheme';
|
||||
import './StyleView.css';
|
||||
|
||||
@@ -12,6 +13,7 @@ function toDisplayName(theme: PicoThemeName): string {
|
||||
}
|
||||
|
||||
export const StyleView: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { activeProject, picoTheme, setPicoTheme } = useAppStore();
|
||||
const [selectedTheme, setSelectedTheme] = useState<PicoThemeName>(getRendererPicoTheme(picoTheme));
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>('auto');
|
||||
@@ -51,10 +53,10 @@ export const StyleView: React.FC = () => {
|
||||
setIsApplying(true);
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ picoTheme: selectedTheme });
|
||||
setPicoTheme(selectedTheme);
|
||||
showToast.success(`Applied theme: ${toDisplayName(selectedTheme)}`);
|
||||
showToast.success(t('styleView.toast.appliedTheme', { theme: toDisplayName(selectedTheme) }));
|
||||
} catch (error) {
|
||||
console.error('Failed to apply style theme:', error);
|
||||
showToast.error('Failed to apply theme');
|
||||
showToast.error(t('styleView.toast.applyThemeFailed'));
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
@@ -63,11 +65,11 @@ export const StyleView: React.FC = () => {
|
||||
return (
|
||||
<div className="style-view">
|
||||
<div className="style-view-header">
|
||||
<h2>Style</h2>
|
||||
<p>Select a Pico CSS theme and preview the top posts before applying.</p>
|
||||
<h2>{t('styleView.title')}</h2>
|
||||
<p>{t('styleView.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="style-theme-picker" role="group" aria-label="Pico Theme Picker">
|
||||
<div className="style-theme-picker" role="group" aria-label={t('styleView.themePickerAria')}>
|
||||
{PICO_THEME_NAMES.map((theme) => {
|
||||
const isSelected = selectedTheme === theme;
|
||||
const preview = PICO_THEME_PREVIEW_TOKENS[theme];
|
||||
@@ -104,15 +106,15 @@ export const StyleView: React.FC = () => {
|
||||
|
||||
<div className="style-apply-row">
|
||||
<label className="style-preview-mode-control">
|
||||
<span>Preview mode</span>
|
||||
<span>{t('styleView.previewMode')}</span>
|
||||
<select
|
||||
aria-label="Preview mode"
|
||||
aria-label={t('styleView.previewMode')}
|
||||
value={previewMode}
|
||||
onChange={(event) => setPreviewMode(event.target.value as PreviewMode)}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="auto">{t('styleView.mode.auto')}</option>
|
||||
<option value="light">{t('styleView.mode.light')}</option>
|
||||
<option value="dark">{t('styleView.mode.dark')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
@@ -120,13 +122,13 @@ export const StyleView: React.FC = () => {
|
||||
onClick={handleApplyTheme}
|
||||
disabled={isApplying || picoTheme === selectedTheme}
|
||||
>
|
||||
Apply Theme
|
||||
{t('styleView.applyTheme')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="style-preview-container">
|
||||
<iframe
|
||||
title="Theme preview"
|
||||
title={t('styleView.themePreviewTitle')}
|
||||
className="style-preview-frame"
|
||||
src={previewUrl}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAppStore } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { getContrastColor } from '../../utils/color';
|
||||
import { subscribeToTagEvents } from '../../utils/tagEventSubscriptions';
|
||||
import { useI18n } from '../../i18n';
|
||||
import './TagsView.css';
|
||||
|
||||
// Types
|
||||
@@ -46,7 +47,8 @@ const TagCloudItem: React.FC<{
|
||||
isSelected: boolean;
|
||||
onSelect: (name: string) => void;
|
||||
maxCount: number;
|
||||
}> = ({ tag, isSelected, onSelect, maxCount }) => {
|
||||
title: string;
|
||||
}> = ({ tag, isSelected, onSelect, maxCount, title }) => {
|
||||
// Calculate font size based on count (range: 0.8rem to 2rem)
|
||||
const minSize = 0.85;
|
||||
const maxSize = 1.8;
|
||||
@@ -69,7 +71,7 @@ const TagCloudItem: React.FC<{
|
||||
className={`tag-cloud-item ${isSelected ? 'selected' : ''} ${hasColor ? 'has-color' : ''}`}
|
||||
style={style}
|
||||
onClick={() => onSelect(tag.name)}
|
||||
title={`${tag.count} post${tag.count !== 1 ? 's' : ''}`}
|
||||
title={title}
|
||||
>
|
||||
{tag.name}
|
||||
<span className="tag-count">{tag.count}</span>
|
||||
@@ -128,6 +130,7 @@ const SectionHeader: React.FC<{
|
||||
);
|
||||
|
||||
export const TagsView: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { showErrorModal } = useAppStore();
|
||||
|
||||
// State
|
||||
@@ -198,7 +201,7 @@ export const TagsView: React.FC = () => {
|
||||
// Create tag
|
||||
const handleCreateTag = async () => {
|
||||
if (!newTagName.trim()) {
|
||||
showToast.error('Tag name is required');
|
||||
showToast.error(t('tagsView.toast.tagNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -209,7 +212,7 @@ export const TagsView: React.FC = () => {
|
||||
});
|
||||
setNewTagName('');
|
||||
setNewTagColor('');
|
||||
showToast.success('Tag created');
|
||||
showToast.success(t('tagsView.toast.tagCreated'));
|
||||
loadTags();
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
@@ -224,14 +227,14 @@ export const TagsView: React.FC = () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.tags.delete(deleteConfirm.tagId);
|
||||
if (result?.success) {
|
||||
showToast.success(`Tag deleted. ${result.postsUpdated} post(s) updated.`);
|
||||
showToast.success(t('tagsView.toast.tagDeleted', { postsUpdated: result.postsUpdated }));
|
||||
setSelectedTags(prev => prev.filter(n => n !== deleteConfirm.tagName));
|
||||
loadTags();
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
showErrorModal({
|
||||
title: 'Delete Failed',
|
||||
title: t('tagsView.error.deleteFailedTitle'),
|
||||
message: err.message,
|
||||
});
|
||||
} finally {
|
||||
@@ -262,7 +265,7 @@ export const TagsView: React.FC = () => {
|
||||
await window.electronAPI?.tags.rename(editingTagId, editTagName.trim());
|
||||
}
|
||||
|
||||
showToast.success('Tag updated');
|
||||
showToast.success(t('tagsView.toast.tagUpdated'));
|
||||
setEditingTagId(null);
|
||||
loadTags();
|
||||
} catch (error) {
|
||||
@@ -279,7 +282,7 @@ export const TagsView: React.FC = () => {
|
||||
// Find target tag
|
||||
const targetTag = allTags.find(t => t.name === mergeConfirm.targetName);
|
||||
if (!targetTag) {
|
||||
showToast.error('Target tag not found');
|
||||
showToast.error(t('tagsView.toast.targetTagNotFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -289,7 +292,7 @@ export const TagsView: React.FC = () => {
|
||||
);
|
||||
|
||||
if (sourceTags.length === 0) {
|
||||
showToast.error('No source tags to merge');
|
||||
showToast.error(t('tagsView.toast.noSourceTagsToMerge'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -300,7 +303,11 @@ export const TagsView: React.FC = () => {
|
||||
|
||||
if (result?.success) {
|
||||
showToast.success(
|
||||
`Merged ${result.tagsDeleted} tag(s) into "${result.targetTag}". ${result.postsUpdated} post(s) updated.`
|
||||
t('tagsView.toast.tagsMerged', {
|
||||
tagsDeleted: result.tagsDeleted,
|
||||
targetTag: result.targetTag,
|
||||
postsUpdated: result.postsUpdated,
|
||||
})
|
||||
);
|
||||
setSelectedTags([]);
|
||||
setMergeTargetName('');
|
||||
@@ -309,7 +316,7 @@ export const TagsView: React.FC = () => {
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
showErrorModal({
|
||||
title: 'Merge Failed',
|
||||
title: t('tagsView.error.mergeFailedTitle'),
|
||||
message: err.message,
|
||||
});
|
||||
} finally {
|
||||
@@ -323,9 +330,9 @@ export const TagsView: React.FC = () => {
|
||||
const result = await window.electronAPI?.tags.syncFromPosts();
|
||||
if (result) {
|
||||
if (result.added.length > 0) {
|
||||
showToast.success(`Discovered ${result.added.length} new tag(s)`);
|
||||
showToast.success(t('tagsView.toast.discoveredTags', { count: result.added.length }));
|
||||
} else {
|
||||
showToast.info('All tags are already synced');
|
||||
showToast.info(t('tagsView.toast.alreadySynced'));
|
||||
}
|
||||
loadTags();
|
||||
}
|
||||
@@ -349,23 +356,23 @@ export const TagsView: React.FC = () => {
|
||||
return (
|
||||
<div className="tags-view">
|
||||
<div className="tags-view-header">
|
||||
<h2>Tag Management</h2>
|
||||
<p className="text-muted">Manage your blog's tags, assign colors, and perform bulk operations.</p>
|
||||
<h2>{t('tagsView.title')}</h2>
|
||||
<p className="text-muted">{t('tagsView.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="tags-view-content">
|
||||
{/* Tag Cloud Section */}
|
||||
<SectionHeader
|
||||
id="tags-section-cloud"
|
||||
title="Tag Cloud"
|
||||
description="Click tags to select them for bulk operations. Hover to see post counts."
|
||||
title={t('tagsView.cloud.title')}
|
||||
description={t('tagsView.cloud.description')}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="tags-loading">Loading tags...</div>
|
||||
<div className="tags-loading">{t('tagsView.loadingTags')}</div>
|
||||
) : tagsWithCounts.length === 0 ? (
|
||||
<div className="tags-empty">
|
||||
<p>No tags found</p>
|
||||
<button onClick={handleSyncFromPosts}>Discover tags from posts</button>
|
||||
<p>{t('tagsView.noTagsFound')}</p>
|
||||
<button onClick={handleSyncFromPosts}>{t('tagsView.discoverFromPosts')}</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -377,13 +384,17 @@ export const TagsView: React.FC = () => {
|
||||
isSelected={selectedTags.includes(tag.name)}
|
||||
onSelect={handleTagSelect}
|
||||
maxCount={maxCount}
|
||||
title={t('tagsView.tagCountTitle', {
|
||||
count: tag.count,
|
||||
item: tag.count === 1 ? t('tagsView.postsSingular') : t('tagsView.postsPlural'),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="tag-selection-info">
|
||||
<span>{selectedTags.length} tag(s) selected</span>
|
||||
<button onClick={handleClearSelection}>Clear selection</button>
|
||||
<span>{t('tagsView.selectedCount', { count: selectedTags.length })}</span>
|
||||
<button onClick={handleClearSelection}>{t('tagsView.clearSelection')}</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -393,16 +404,16 @@ export const TagsView: React.FC = () => {
|
||||
{/* Tag Management Section */}
|
||||
<SectionHeader
|
||||
id="tags-section-manage"
|
||||
title="Create & Edit Tags"
|
||||
description="Create new tags or edit existing ones. Assign colors to make tags visually distinct."
|
||||
title={t('tagsView.manage.title')}
|
||||
description={t('tagsView.manage.description')}
|
||||
>
|
||||
{/* Create new tag */}
|
||||
<div className="tag-create-form">
|
||||
<h4>Create New Tag</h4>
|
||||
<h4>{t('tagsView.create.title')}</h4>
|
||||
<div className="tag-form-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tag name"
|
||||
placeholder={t('tagsView.tagNamePlaceholder')}
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateTag()}
|
||||
@@ -412,19 +423,19 @@ export const TagsView: React.FC = () => {
|
||||
type="color"
|
||||
value={newTagColor || '#808080'}
|
||||
onChange={(e) => setNewTagColor(e.target.value)}
|
||||
title="Choose color"
|
||||
title={t('tagsView.chooseColor')}
|
||||
/>
|
||||
{newTagColor && (
|
||||
<button
|
||||
className="clear-color"
|
||||
onClick={() => setNewTagColor('')}
|
||||
title="Remove color"
|
||||
title={t('tagsView.removeColor')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={handleCreateTag} className="primary">Create</button>
|
||||
<button onClick={handleCreateTag} className="primary">{t('tagsView.create.action')}</button>
|
||||
</div>
|
||||
<div className="color-presets">
|
||||
{COLOR_PRESETS.map(color => (
|
||||
@@ -442,14 +453,14 @@ export const TagsView: React.FC = () => {
|
||||
{/* Selected tag editor */}
|
||||
{selectedTagObjects.length === 1 && (
|
||||
<div className="tag-edit-form">
|
||||
<h4>Edit Tag: {selectedTagObjects[0].name}</h4>
|
||||
<h4>{t('tagsView.edit.title', { name: selectedTagObjects[0].name })}</h4>
|
||||
{editingTagId === selectedTagObjects[0].id ? (
|
||||
<div className="tag-form-row">
|
||||
<input
|
||||
type="text"
|
||||
value={editTagName}
|
||||
onChange={(e) => setEditTagName(e.target.value)}
|
||||
placeholder="Tag name"
|
||||
placeholder={t('tagsView.tagNamePlaceholder')}
|
||||
/>
|
||||
<div className="color-picker-group">
|
||||
<input
|
||||
@@ -461,14 +472,14 @@ export const TagsView: React.FC = () => {
|
||||
<button
|
||||
className="clear-color"
|
||||
onClick={() => setEditTagColor('')}
|
||||
title="Remove color"
|
||||
title={t('tagsView.removeColor')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={handleSaveEdit} className="primary">Save</button>
|
||||
<button onClick={() => setEditingTagId(null)}>Cancel</button>
|
||||
<button onClick={handleSaveEdit} className="primary">{t('common.save')}</button>
|
||||
<button onClick={() => setEditingTagId(null)}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tag-form-row">
|
||||
@@ -480,7 +491,7 @@ export const TagsView: React.FC = () => {
|
||||
{selectedTagObjects[0].name}
|
||||
</span>
|
||||
<button onClick={() => handleStartEdit(selectedTagObjects[0])}>
|
||||
Edit
|
||||
{t('tagsView.edit.action')}
|
||||
</button>
|
||||
<button
|
||||
className="danger"
|
||||
@@ -489,7 +500,7 @@ export const TagsView: React.FC = () => {
|
||||
tagName: selectedTagObjects[0].name
|
||||
})}
|
||||
>
|
||||
Delete
|
||||
{t('tagsView.deleteAction')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -500,20 +511,20 @@ export const TagsView: React.FC = () => {
|
||||
{/* Merge Tags Section */}
|
||||
<SectionHeader
|
||||
id="tags-section-merge"
|
||||
title="Merge Tags"
|
||||
description="Select multiple tags above, then merge them into a single tag. All posts will be updated."
|
||||
title={t('tagsView.merge.title')}
|
||||
description={t('tagsView.merge.description')}
|
||||
>
|
||||
{selectedTags.length < 2 ? (
|
||||
<p className="text-muted">Select 2 or more tags from the cloud above to merge them.</p>
|
||||
<p className="text-muted">{t('tagsView.merge.selectAtLeastTwo')}</p>
|
||||
) : (
|
||||
<div className="merge-form">
|
||||
<p>Merge <strong>{selectedTags.length}</strong> tags into:</p>
|
||||
<p>{t('tagsView.merge.countInto', { count: selectedTags.length })}</p>
|
||||
<div className="tag-form-row">
|
||||
<select
|
||||
value={mergeTargetName}
|
||||
onChange={(e) => setMergeTargetName(e.target.value)}
|
||||
>
|
||||
<option value="">Select target tag...</option>
|
||||
<option value="">{t('tagsView.merge.selectTarget')}</option>
|
||||
{selectedTags.map(name => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
@@ -530,11 +541,13 @@ export const TagsView: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Merge Tags
|
||||
{t('tagsView.merge.action')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-muted text-small">
|
||||
Tags to be deleted: {selectedTags.filter(n => n !== mergeTargetName).join(', ') || '(none)'}
|
||||
{t('tagsView.merge.tagsToDelete', {
|
||||
tags: selectedTags.filter(n => n !== mergeTargetName).join(', ') || t('tagsView.none'),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -543,11 +556,11 @@ export const TagsView: React.FC = () => {
|
||||
{/* Sync Section */}
|
||||
<SectionHeader
|
||||
id="tags-section-sync"
|
||||
title="Sync Tags"
|
||||
description="Discover tags that exist in posts but not in the tag database."
|
||||
title={t('tagsView.sync.title')}
|
||||
description={t('tagsView.sync.description')}
|
||||
>
|
||||
<button onClick={handleSyncFromPosts}>
|
||||
Sync Tags from Posts
|
||||
{t('tagsView.sync.action')}
|
||||
</button>
|
||||
</SectionHeader>
|
||||
</div>
|
||||
@@ -555,9 +568,10 @@ export const TagsView: React.FC = () => {
|
||||
{/* Confirm Dialogs */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteConfirm}
|
||||
title="Delete Tag"
|
||||
message={`Are you sure you want to delete the tag "${deleteConfirm?.tagName}"? This will remove it from all posts. This action runs as a background task.`}
|
||||
confirmText="Delete Tag"
|
||||
title={t('tagsView.confirmDelete.title')}
|
||||
message={t('tagsView.confirmDelete.message', { tagName: deleteConfirm?.tagName || '' })}
|
||||
confirmText={t('tagsView.confirmDelete.action')}
|
||||
cancelText={t('common.cancel')}
|
||||
isDestructive
|
||||
onConfirm={handleDeleteTag}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
@@ -565,9 +579,13 @@ export const TagsView: React.FC = () => {
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!mergeConfirm}
|
||||
title="Merge Tags"
|
||||
message={`Are you sure you want to merge ${mergeConfirm?.sourceNames.length} tag(s) into "${mergeConfirm?.targetName}"? The source tags will be deleted and all posts will be updated. This runs as a background task.`}
|
||||
confirmText="Merge Tags"
|
||||
title={t('tagsView.confirmMerge.title')}
|
||||
message={t('tagsView.confirmMerge.message', {
|
||||
count: mergeConfirm?.sourceNames.length || 0,
|
||||
target: mergeConfirm?.targetName || '',
|
||||
})}
|
||||
confirmText={t('tagsView.confirmMerge.action')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={handleMergeTags}
|
||||
onCancel={() => setMergeConfirm(null)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user