feat: i18n support with first translations
This commit is contained in:
14
.github/copilot-instructions.md
vendored
14
.github/copilot-instructions.md
vendored
@@ -85,6 +85,20 @@ See the [TDD Requirements](#test-driven-development-tdd-requirements) section fo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
|
||||||
|
|
||||||
|
**All user-facing text MUST follow proper i18n patterns.**
|
||||||
|
|
||||||
|
- Do not hardcode UI strings directly in React components, menu templates, dialogs, or toasts
|
||||||
|
- Store UI copy in language resources and resolve text through i18n helpers/hooks
|
||||||
|
- UI language MUST come from the operating system locale
|
||||||
|
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
|
||||||
|
- Keep i18n usage consistent in both renderer UI and render/preview output
|
||||||
|
|
||||||
|
> **No hardcoded user-facing text. No exceptions.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture Principles
|
## Architecture Principles
|
||||||
|
|
||||||
### Separation of Concerns
|
### Separation of Concerns
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { MediaData } from './MediaEngine';
|
|||||||
import type { PostData } from './PostEngine';
|
import type { PostData } from './PostEngine';
|
||||||
import { PICO_THEME_NAMES } from '../shared/picoThemes';
|
import { PICO_THEME_NAMES } from '../shared/picoThemes';
|
||||||
import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime';
|
import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime';
|
||||||
|
import { resolveRenderLanguageFromProjectPreferences, translateRender } from '../shared/i18n';
|
||||||
|
|
||||||
export interface HtmlRewriteContext {
|
export interface HtmlRewriteContext {
|
||||||
canonicalPostPathBySlug: Map<string, string>;
|
canonicalPostPathBySlug: Map<string, string>;
|
||||||
@@ -87,6 +88,8 @@ export interface SinglePostTemplateContext {
|
|||||||
export interface NotFoundTemplateContext {
|
export interface NotFoundTemplateContext {
|
||||||
page_title: string;
|
page_title: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
not_found_message?: string;
|
||||||
|
not_found_back_label?: string;
|
||||||
pico_stylesheet_href?: string;
|
pico_stylesheet_href?: string;
|
||||||
html_theme_attribute?: string;
|
html_theme_attribute?: string;
|
||||||
}
|
}
|
||||||
@@ -336,7 +339,9 @@ export function renderGalleryMacro(
|
|||||||
postId: string,
|
postId: string,
|
||||||
mediaItems: MediaData[],
|
mediaItems: MediaData[],
|
||||||
linkedMediaIds: Set<string> | null,
|
linkedMediaIds: Set<string> | null,
|
||||||
|
renderLanguage: string,
|
||||||
): string {
|
): string {
|
||||||
|
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
|
||||||
const requestedColumns = parseIntegerParam(params.columns);
|
const requestedColumns = parseIntegerParam(params.columns);
|
||||||
const columns = requestedColumns && requestedColumns >= 1 && requestedColumns <= 6 ? requestedColumns : 3;
|
const columns = requestedColumns && requestedColumns >= 1 && requestedColumns <= 6 ? requestedColumns : 3;
|
||||||
const caption = params.caption ? `<figcaption class="gallery-caption">${escapeHtml(params.caption)}</figcaption>` : '';
|
const caption = params.caption ? `<figcaption class="gallery-caption">${escapeHtml(params.caption)}</figcaption>` : '';
|
||||||
@@ -361,12 +366,16 @@ export function renderGalleryMacro(
|
|||||||
return `<a class="gallery-item" href="${mediaPath}" data-lightbox="${groupName}" data-title="${title}"><img src="${mediaPath}" alt="${alt}" loading="lazy" /></a>`;
|
return `<a class="gallery-item" href="${mediaPath}" data-lightbox="${groupName}" data-title="${title}"><img src="${mediaPath}" alt="${alt}" loading="lazy" /></a>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
const content = galleryItems || '<div class="gallery-empty">No linked images found.</div>';
|
const content = galleryItems || `<div class="gallery-empty">${escapeHtml(translateRender(language, 'render.gallery.empty'))}</div>`;
|
||||||
return `<div class="macro-gallery gallery-cols-${columns}" data-post-id="${escapeHtml(postId)}" data-columns="${columns}" data-lightbox="true"><div class="gallery-container gallery-lightbox">${content}</div>${caption}</div>`;
|
return `<div class="macro-gallery gallery-cols-${columns}" data-post-id="${escapeHtml(postId)}" data-columns="${columns}" data-lightbox="true"><div class="gallery-container gallery-lightbox">${content}</div>${caption}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderPhotoArchiveMacro(params: Record<string, string>, mediaItems: MediaData[]): string {
|
export function renderPhotoArchiveMacro(
|
||||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
params: Record<string, string>,
|
||||||
|
mediaItems: MediaData[],
|
||||||
|
renderLanguage: string,
|
||||||
|
): string {
|
||||||
|
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
|
||||||
const yearParam = parseIntegerParam(params.year);
|
const yearParam = parseIntegerParam(params.year);
|
||||||
const monthParam = parseIntegerParam(params.month);
|
const monthParam = parseIntegerParam(params.month);
|
||||||
|
|
||||||
@@ -393,11 +402,12 @@ export function renderPhotoArchiveMacro(params: Record<string, string>, mediaIte
|
|||||||
const buckets = buildPhotoArchiveBuckets(renderableMedia, params);
|
const buckets = buildPhotoArchiveBuckets(renderableMedia, params);
|
||||||
|
|
||||||
if (buckets.length === 0) {
|
if (buckets.length === 0) {
|
||||||
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container"><div class="photo-archive-empty">No photos found for this archive.</div></div></div>`;
|
const emptyLabel = escapeHtml(translateRender(language, 'render.photoArchive.empty'));
|
||||||
|
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container"><div class="photo-archive-empty">${emptyLabel}</div></div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const monthsHtml = buckets.map((bucket) => {
|
const monthsHtml = buckets.map((bucket) => {
|
||||||
const monthName = monthNames[bucket.month - 1] || String(bucket.month);
|
const monthName = translateRender(language, `render.month.${bucket.month}`);
|
||||||
const label = `${monthName} ${bucket.year}`;
|
const label = `${monthName} ${bucket.year}`;
|
||||||
const groupName = `photo-archive-${bucket.year}-${String(bucket.month).padStart(2, '0')}`;
|
const groupName = `photo-archive-${bucket.year}-${String(bucket.month).padStart(2, '0')}`;
|
||||||
|
|
||||||
@@ -414,7 +424,8 @@ export function renderPhotoArchiveMacro(params: Record<string, string>, mediaIte
|
|||||||
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container">${monthsHtml}</div></div>`;
|
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container">${monthsHtml}</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderTagCloudMacro(params: Record<string, string>, tagUsage: TagUsageEntry[]): string {
|
export function renderTagCloudMacro(params: Record<string, string>, tagUsage: TagUsageEntry[], renderLanguage: string): string {
|
||||||
|
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
|
||||||
const widthParam = parseIntegerParam(params.width);
|
const widthParam = parseIntegerParam(params.width);
|
||||||
const heightParam = parseIntegerParam(params.height);
|
const heightParam = parseIntegerParam(params.height);
|
||||||
const orientation = normalizeTagCloudOrientation(params.orientation);
|
const orientation = normalizeTagCloudOrientation(params.orientation);
|
||||||
@@ -422,7 +433,7 @@ export function renderTagCloudMacro(params: Record<string, string>, tagUsage: Ta
|
|||||||
const height = heightParam && heightParam >= 180 && heightParam <= 900 ? heightParam : 420;
|
const height = heightParam && heightParam >= 180 && heightParam <= 900 ? heightParam : 420;
|
||||||
|
|
||||||
if (tagUsage.length === 0) {
|
if (tagUsage.length === 0) {
|
||||||
return `<div class="macro-tag-cloud" data-tag-cloud="true" data-orientation="${orientation}" data-color-distribution="quantile" data-color-easing="0.7" data-color-theme="pico"><div class="tag-cloud-empty">No tags found.</div></div>`;
|
return `<div class="macro-tag-cloud" data-tag-cloud="true" data-orientation="${orientation}" data-color-distribution="quantile" data-color-easing="0.7" data-color-theme="pico"><div class="tag-cloud-empty">${escapeHtml(translateRender(language, 'render.tagCloud.empty'))}</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const minCount = Math.min(...tagUsage.map((entry) => entry.count));
|
const minCount = Math.min(...tagUsage.map((entry) => entry.count));
|
||||||
@@ -445,7 +456,8 @@ export function renderTagCloudMacro(params: Record<string, string>, tagUsage: Ta
|
|||||||
|
|
||||||
const wordsJson = escapeHtml(JSON.stringify(words));
|
const wordsJson = escapeHtml(JSON.stringify(words));
|
||||||
|
|
||||||
return `<div class="macro-tag-cloud" data-tag-cloud="true" data-orientation="${orientation}" data-color-distribution="quantile" data-color-easing="0.7" data-color-theme="pico" data-tag-cloud-words="${wordsJson}" data-width="${width}" data-height="${height}"><svg class="tag-cloud-canvas" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" aria-label="Tag cloud"></svg></div>`;
|
const ariaLabel = escapeHtml(translateRender(language, 'render.tagCloud.ariaLabel'));
|
||||||
|
return `<div class="macro-tag-cloud" data-tag-cloud="true" data-orientation="${orientation}" data-color-distribution="quantile" data-color-easing="0.7" data-color-theme="pico" data-tag-cloud-words="${wordsJson}" data-width="${width}" data-height="${height}"><svg class="tag-cloud-canvas" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" aria-label="${ariaLabel}"></svg></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isExternalOrSpecialUrl(value: string): boolean {
|
export function isExternalOrSpecialUrl(value: string): boolean {
|
||||||
@@ -551,33 +563,36 @@ export function renderMacro(
|
|||||||
mediaItems: MediaData[],
|
mediaItems: MediaData[],
|
||||||
linkedMediaIds: Set<string> | null,
|
linkedMediaIds: Set<string> | null,
|
||||||
tagUsage: TagUsageEntry[],
|
tagUsage: TagUsageEntry[],
|
||||||
|
renderLanguage: string,
|
||||||
): string {
|
): string {
|
||||||
const normalizedName = normalizeMacroName(name);
|
const normalizedName = normalizeMacroName(name);
|
||||||
|
|
||||||
if (normalizedName === 'youtube') {
|
if (normalizedName === 'youtube') {
|
||||||
|
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
|
||||||
const id = escapeHtml(params.id || '');
|
const id = escapeHtml(params.id || '');
|
||||||
const title = escapeHtml(params.title || 'YouTube video');
|
const title = escapeHtml(params.title || translateRender(language, 'render.video.youtubeTitle'));
|
||||||
if (!id) return '';
|
if (!id) return '';
|
||||||
return `<div class="macro-youtube"><iframe src="https://www.youtube.com/embed/${id}?rel=0" title="${title}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>`;
|
return `<div class="macro-youtube"><iframe src="https://www.youtube.com/embed/${id}?rel=0" title="${title}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedName === 'vimeo') {
|
if (normalizedName === 'vimeo') {
|
||||||
|
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
|
||||||
const id = escapeHtml(params.id || '');
|
const id = escapeHtml(params.id || '');
|
||||||
const title = escapeHtml(params.title || 'Vimeo video');
|
const title = escapeHtml(params.title || translateRender(language, 'render.video.vimeoTitle'));
|
||||||
if (!id) return '';
|
if (!id) return '';
|
||||||
return `<div class="macro-vimeo"><iframe src="https://player.vimeo.com/video/${id}" title="${title}" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe></div>`;
|
return `<div class="macro-vimeo"><iframe src="https://player.vimeo.com/video/${id}" title="${title}" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedName === 'gallery') {
|
if (normalizedName === 'gallery') {
|
||||||
return renderGalleryMacro(params, postId, mediaItems, linkedMediaIds);
|
return renderGalleryMacro(params, postId, mediaItems, linkedMediaIds, renderLanguage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedName === 'photo_archive') {
|
if (normalizedName === 'photo_archive') {
|
||||||
return renderPhotoArchiveMacro(params, mediaItems);
|
return renderPhotoArchiveMacro(params, mediaItems, renderLanguage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedName === 'tag_cloud') {
|
if (normalizedName === 'tag_cloud') {
|
||||||
return renderTagCloudMacro(params, tagUsage);
|
return renderTagCloudMacro(params, tagUsage, renderLanguage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
@@ -677,9 +692,23 @@ export class PageRenderer {
|
|||||||
cache: true,
|
cache: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown) => {
|
this.liquid.registerFilter('i18n', (keyArg: unknown, renderLanguageArg: unknown) => {
|
||||||
|
const key = typeof keyArg === 'string' ? keyArg : '';
|
||||||
|
const resolved = resolveRenderLanguageFromProjectPreferences(
|
||||||
|
typeof renderLanguageArg === 'string' ? renderLanguageArg : 'en',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return translateRender(resolved, key);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown, renderLanguageArg: unknown) => {
|
||||||
const content = typeof value === 'string' ? value : '';
|
const content = typeof value === 'string' ? value : '';
|
||||||
const postId = typeof postIdArg === 'string' ? postIdArg : '';
|
const postId = typeof postIdArg === 'string' ? postIdArg : '';
|
||||||
|
const renderLanguage = typeof renderLanguageArg === 'string' ? renderLanguageArg : 'en';
|
||||||
const rewriteContext: HtmlRewriteContext = {
|
const rewriteContext: HtmlRewriteContext = {
|
||||||
canonicalPostPathBySlug: recordToMap(canonicalPostsArg),
|
canonicalPostPathBySlug: recordToMap(canonicalPostsArg),
|
||||||
canonicalMediaPathBySourcePath: recordToMap(canonicalMediaArg),
|
canonicalMediaPathBySourcePath: recordToMap(canonicalMediaArg),
|
||||||
@@ -703,7 +732,7 @@ export class PageRenderer {
|
|||||||
|
|
||||||
const withMacros = content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => {
|
const withMacros = content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => {
|
||||||
const params = parseMacroParams(rawParams);
|
const params = parseMacroParams(rawParams);
|
||||||
return renderMacro(macroName.toLowerCase(), params, postId, mediaItems, linkedMediaIds, tagUsage);
|
return renderMacro(macroName.toLowerCase(), params, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage);
|
||||||
});
|
});
|
||||||
|
|
||||||
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
|
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
|
||||||
@@ -964,6 +993,10 @@ export class PageRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async renderNotFound(context: NotFoundTemplateContext): Promise<string> {
|
async renderNotFound(context: NotFoundTemplateContext): Promise<string> {
|
||||||
return this.liquid.renderFile('not-found', context);
|
return this.liquid.renderFile('not-found', {
|
||||||
|
...context,
|
||||||
|
not_found_message: context.not_found_message,
|
||||||
|
not_found_back_label: context.not_found_back_label,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
<section class="not-found" data-template="not-found">
|
<section class="not-found" data-template="not-found">
|
||||||
<article>
|
<article>
|
||||||
<h1>404</h1>
|
<h1>404</h1>
|
||||||
<p>The requested preview page could not be found.</p>
|
{% assign default_not_found_message = 'render.notFound.message' | i18n: language %}
|
||||||
<p><a href="/" role="button">Back to preview home</a></p>
|
{% assign default_not_found_back = 'render.notFound.back' | i18n: language %}
|
||||||
|
<p>{{ not_found_message | default: default_not_found_message }}</p>
|
||||||
|
<p><a href="/" role="button">{{ not_found_back_label | default: default_not_found_back }}</a></p>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,26 +4,23 @@
|
|||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
{% if archive_context %}
|
{% if archive_context %}
|
||||||
{% assign month_names_de = "Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember" | split: "|" %}
|
|
||||||
{% if show_archive_range_heading and min_date and max_date %}
|
{% if show_archive_range_heading and min_date and max_date %}
|
||||||
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
|
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
|
||||||
<h1 class="archive-heading">{{ archive_context.name }} - {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
|
<h1 class="archive-heading">{{ archive_context.name }} - {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h1 class="archive-heading">Archiv {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
|
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
|
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
|
||||||
<h1 class="archive-heading">{{ archive_context.name }}</h1>
|
<h1 class="archive-heading">{{ archive_context.name }}</h1>
|
||||||
{% elsif archive_context.kind == 'month' and archive_context.month and archive_context.year %}
|
{% elsif archive_context.kind == 'month' and archive_context.month and archive_context.year %}
|
||||||
{% assign month_index = archive_context.month | minus: 1 %}
|
{% assign month_key = 'render.month.' | append: archive_context.month %}
|
||||||
{% assign month_name = month_names_de[month_index] %}
|
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ month_key | i18n: language }} {{ archive_context.year }}</h1>
|
||||||
<h1 class="archive-heading">Archiv {{ month_name }} {{ archive_context.year }}</h1>
|
|
||||||
{% elsif archive_context.kind == 'year' and archive_context.year %}
|
{% elsif archive_context.kind == 'year' and archive_context.year %}
|
||||||
<h1 class="archive-heading">Archiv {{ archive_context.year }}</h1>
|
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ archive_context.year }}</h1>
|
||||||
{% elsif archive_context.kind == 'day' and archive_context.day and archive_context.month and archive_context.year %}
|
{% elsif archive_context.kind == 'day' and archive_context.day and archive_context.month and archive_context.year %}
|
||||||
{% assign day_month_index = archive_context.month | minus: 1 %}
|
{% assign day_month_key = 'render.month.' | append: archive_context.month %}
|
||||||
{% assign day_month_name = month_names_de[day_month_index] %}
|
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ archive_context.day }}. {{ day_month_key | i18n: language }} {{ archive_context.year }}</h1>
|
||||||
<h1 class="archive-heading">Archiv {{ archive_context.day }}. {{ day_month_name }} {{ archive_context.year }}</h1>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<h1 class="archive-heading">{{ page_title }}</h1>
|
<h1 class="archive-heading">{{ page_title }}</h1>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -41,7 +38,7 @@
|
|||||||
{% if post.show_title %}
|
{% if post.show_title %}
|
||||||
<h2 class="post-title">{{ post.title }}</h2>
|
<h2 class="post-title">{{ post.title }}</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }}
|
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +49,7 @@
|
|||||||
{% if post.show_title %}
|
{% if post.show_title %}
|
||||||
<h2 class="post-title">{{ post.title }}</h2>
|
<h2 class="post-title">{{ post.title }}</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }}
|
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -64,15 +61,15 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if has_prev_page or has_next_page %}
|
{% if has_prev_page or has_next_page %}
|
||||||
<nav class="preview-pagination" aria-label="Pagination">
|
<nav class="preview-pagination" aria-label="{{ 'render.pagination.label' | i18n: language }}">
|
||||||
{% if has_prev_page %}
|
{% if has_prev_page %}
|
||||||
<a href="{{ prev_page_href }}" class="preview-pagination-link" aria-label="Newer posts">neuer</a>
|
<a href="{{ prev_page_href }}" class="preview-pagination-link" aria-label="{{ 'render.pagination.newer' | i18n: language }}">{{ 'render.pagination.newer' | i18n: language }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if has_next_page %}
|
{% if has_next_page %}
|
||||||
<a href="{{ next_page_href }}" class="preview-pagination-link" aria-label="Older posts">älter</a>
|
<a href="{{ next_page_href }}" class="preview-pagination-link" aria-label="{{ 'render.pagination.older' | i18n: language }}">{{ 'render.pagination.older' | i18n: language }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<main>
|
<main>
|
||||||
<article class="single-post" data-template="single-post">
|
<article class="single-post" data-template="single-post">
|
||||||
<h1>{{ post.title }}</h1>
|
<h1>{{ post.title }}</h1>
|
||||||
<div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path }}</div>
|
<div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div>
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -222,6 +222,10 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.deleteProjectWithData(id);
|
return engine.deleteProjectWithData(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
safeHandle('app:getSystemLanguage', async () => {
|
||||||
|
return app.getLocale();
|
||||||
|
});
|
||||||
|
|
||||||
safeHandle('projects:get', async (_, id: string) => {
|
safeHandle('projects:get', async (_, id: string) => {
|
||||||
const engine = getProjectEngine();
|
const engine = getProjectEngine();
|
||||||
return engine.getProject(id);
|
return engine.getProject(id);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getMediaEngine } from './engine/MediaEngine';
|
|||||||
import { getPostEngine } from './engine/PostEngine';
|
import { getPostEngine } from './engine/PostEngine';
|
||||||
import { PreviewServer } from './engine/PreviewServer';
|
import { PreviewServer } from './engine/PreviewServer';
|
||||||
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands';
|
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands';
|
||||||
|
import { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let previewServer: PreviewServer | null = null;
|
let previewServer: PreviewServer | null = null;
|
||||||
@@ -170,6 +171,8 @@ async function startPreviewServerOnAppStart(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createApplicationMenu(): Menu {
|
function createApplicationMenu(): Menu {
|
||||||
|
const systemLocale = typeof app.getLocale === 'function' ? app.getLocale() : 'en';
|
||||||
|
const uiLanguage = resolveUiLanguageFromSystemLocale(systemLocale);
|
||||||
const commandDefinitions = APP_MENU_GROUPS
|
const commandDefinitions = APP_MENU_GROUPS
|
||||||
.flatMap(group => group.items)
|
.flatMap(group => group.items)
|
||||||
.filter(item => !item.separator)
|
.filter(item => !item.separator)
|
||||||
@@ -258,22 +261,32 @@ function createApplicationMenu(): Menu {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMenuItemLabel = (action: AppMenuAction, fallback: string): string => {
|
||||||
|
return translateMenu(uiLanguage, `menu.item.${action}`) || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMenuGroupLabel = (groupLabel: string): string => {
|
||||||
|
return translateMenu(uiLanguage, `menu.group.${groupLabel.toLowerCase()}`) || groupLabel;
|
||||||
|
};
|
||||||
|
|
||||||
const buildSharedMenuItem = (action: AppMenuAction): MenuItemConstructorOptions => {
|
const buildSharedMenuItem = (action: AppMenuAction): MenuItemConstructorOptions => {
|
||||||
const definition = commandDefinitions[action];
|
const definition = commandDefinitions[action];
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
throw new Error(`Unknown shared menu action: ${action}`);
|
throw new Error(`Unknown shared menu action: ${action}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const translatedLabel = getMenuItemLabel(action, definition.label);
|
||||||
|
|
||||||
if (definition.role) {
|
if (definition.role) {
|
||||||
return {
|
return {
|
||||||
label: definition.label,
|
label: translatedLabel,
|
||||||
role: definition.role,
|
role: definition.role,
|
||||||
accelerator: definition.accelerator,
|
accelerator: definition.accelerator,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: definition.label,
|
label: translatedLabel,
|
||||||
accelerator: definition.accelerator,
|
accelerator: definition.accelerator,
|
||||||
id: definition.id,
|
id: definition.id,
|
||||||
enabled: definition.enabled,
|
enabled: definition.enabled,
|
||||||
@@ -302,23 +315,23 @@ function createApplicationMenu(): Menu {
|
|||||||
|
|
||||||
const template: MenuItemConstructorOptions[] = [
|
const template: MenuItemConstructorOptions[] = [
|
||||||
{
|
{
|
||||||
label: 'File',
|
label: getMenuGroupLabel('File'),
|
||||||
submenu: buildSharedGroupMenuItems('File'),
|
submenu: buildSharedGroupMenuItems('File'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: getMenuGroupLabel('Edit'),
|
||||||
submenu: buildSharedGroupMenuItems('Edit'),
|
submenu: buildSharedGroupMenuItems('Edit'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'View',
|
label: getMenuGroupLabel('View'),
|
||||||
submenu: buildSharedGroupMenuItems('View'),
|
submenu: buildSharedGroupMenuItems('View'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Blog',
|
label: getMenuGroupLabel('Blog'),
|
||||||
submenu: buildSharedGroupMenuItems('Blog'),
|
submenu: buildSharedGroupMenuItems('Blog'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Help',
|
label: getMenuGroupLabel('Help'),
|
||||||
submenu: buildSharedGroupMenuItems('Help'),
|
submenu: buildSharedGroupMenuItems('Help'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export const electronAPI: ElectronAPI = {
|
|||||||
// App
|
// App
|
||||||
app: {
|
app: {
|
||||||
getDataPaths: () => ipcRenderer.invoke('app:getDataPaths'),
|
getDataPaths: () => ipcRenderer.invoke('app:getDataPaths'),
|
||||||
|
getSystemLanguage: () => ipcRenderer.invoke('app:getSystemLanguage'),
|
||||||
getTitleBarMetrics: () => ipcRenderer.invoke('app:getTitleBarMetrics'),
|
getTitleBarMetrics: () => ipcRenderer.invoke('app:getTitleBarMetrics'),
|
||||||
openFolder: (folderPath: string) => ipcRenderer.invoke('app:openFolder', folderPath),
|
openFolder: (folderPath: string) => ipcRenderer.invoke('app:openFolder', folderPath),
|
||||||
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),
|
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ export interface ElectronAPI {
|
|||||||
};
|
};
|
||||||
app: {
|
app: {
|
||||||
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
|
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
|
||||||
|
getSystemLanguage: () => Promise<string>;
|
||||||
getTitleBarMetrics: () => Promise<{ macosLeftInset: number } | null>;
|
getTitleBarMetrics: () => Promise<{ macosLeftInset: number } | null>;
|
||||||
openFolder: (folderPath: string) => Promise<string>;
|
openFolder: (folderPath: string) => Promise<string>;
|
||||||
showItemInFolder: (itemPath: string) => Promise<void>;
|
showItemInFolder: (itemPath: string) => Promise<void>;
|
||||||
|
|||||||
55
src/main/shared/i18n.ts
Normal file
55
src/main/shared/i18n.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import enJson from './i18n/locales/en.json';
|
||||||
|
import deJson from './i18n/locales/de.json';
|
||||||
|
import frJson from './i18n/locales/fr.json';
|
||||||
|
import itJson from './i18n/locales/it.json';
|
||||||
|
import esJson from './i18n/locales/es.json';
|
||||||
|
|
||||||
|
export type SupportedLanguage = 'en' | 'de' | 'fr' | 'it' | 'es';
|
||||||
|
|
||||||
|
type TranslationMap = Record<string, string>;
|
||||||
|
|
||||||
|
const en = enJson as TranslationMap;
|
||||||
|
const de = { ...en, ...(deJson as TranslationMap) };
|
||||||
|
const fr = { ...en, ...(frJson as TranslationMap) };
|
||||||
|
const it = { ...en, ...(itJson as TranslationMap) };
|
||||||
|
const es = { ...en, ...(esJson as TranslationMap) };
|
||||||
|
|
||||||
|
const catalog: Record<SupportedLanguage, TranslationMap> = { en, de, fr, it, es };
|
||||||
|
|
||||||
|
function normalizeLanguage(input: string | undefined | null): SupportedLanguage {
|
||||||
|
const normalized = (input || '').trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = normalized.split('-')[0];
|
||||||
|
if (base === 'en' || base === 'de' || base === 'fr' || base === 'it' || base === 'es') {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSupportedRenderLanguage(language: string | undefined | null): SupportedLanguage {
|
||||||
|
return normalizeLanguage(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRenderLanguageFromProjectPreferences(mainLanguage: string | undefined | null): SupportedLanguage {
|
||||||
|
return normalizeLanguage(mainLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSupportedUiLanguage(language: string | undefined | null): SupportedLanguage {
|
||||||
|
return normalizeLanguage(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUiLanguageFromSystemLocale(systemLocale: string | undefined | null): SupportedLanguage {
|
||||||
|
return normalizeLanguage(systemLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateRender(language: SupportedLanguage, key: string): string {
|
||||||
|
return catalog[language]?.[key] ?? catalog.en[key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateMenu(language: SupportedLanguage, key: string): string {
|
||||||
|
return catalog[language]?.[key] ?? catalog.en[key] ?? key;
|
||||||
|
}
|
||||||
67
src/main/shared/i18n/locales/de.json
Normal file
67
src/main/shared/i18n/locales/de.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"menu.group.file": "Datei",
|
||||||
|
"menu.group.edit": "Bearbeiten",
|
||||||
|
"menu.group.view": "Ansicht",
|
||||||
|
"menu.group.blog": "Blogbereich",
|
||||||
|
"menu.group.help": "Hilfe",
|
||||||
|
"menu.item.newPost": "Neuer Beitrag",
|
||||||
|
"menu.item.importMedia": "Medien importieren...",
|
||||||
|
"menu.item.save": "Speichern",
|
||||||
|
"menu.item.openInBrowser": "Im Browser öffnen",
|
||||||
|
"menu.item.openDataFolder": "Datenordner öffnen",
|
||||||
|
"menu.item.quit": "Beenden",
|
||||||
|
"menu.item.undo": "Rückgängig",
|
||||||
|
"menu.item.redo": "Wiederholen",
|
||||||
|
"menu.item.cut": "Ausschneiden",
|
||||||
|
"menu.item.copy": "Kopieren",
|
||||||
|
"menu.item.paste": "Einfügen",
|
||||||
|
"menu.item.delete": "Löschen",
|
||||||
|
"menu.item.selectAll": "Alles auswählen",
|
||||||
|
"menu.item.find": "Suchen",
|
||||||
|
"menu.item.replace": "Ersetzen",
|
||||||
|
"menu.item.viewPosts": "Beiträge",
|
||||||
|
"menu.item.viewMedia": "Medien",
|
||||||
|
"menu.item.toggleSidebar": "Seitenleiste umschalten",
|
||||||
|
"menu.item.togglePanel": "Panel umschalten",
|
||||||
|
"menu.item.toggleDevTools": "Entwicklerwerkzeuge umschalten",
|
||||||
|
"menu.item.reload": "Neu laden",
|
||||||
|
"menu.item.forceReload": "Erzwungen neu laden",
|
||||||
|
"menu.item.resetZoom": "Tatsächliche Größe",
|
||||||
|
"menu.item.zoomIn": "Vergrößern",
|
||||||
|
"menu.item.zoomOut": "Verkleinern",
|
||||||
|
"menu.item.toggleFullScreen": "Vollbild umschalten",
|
||||||
|
"menu.item.publishSelected": "Ausgewählte veröffentlichen",
|
||||||
|
"menu.item.previewPost": "Beitragsvorschau",
|
||||||
|
"menu.item.rebuildDatabase": "Datenbank aus Dateien neu aufbauen",
|
||||||
|
"menu.item.reindexText": "Suchtext neu indizieren",
|
||||||
|
"menu.item.metadataDiff": "Metadaten-Diff-Werkzeug",
|
||||||
|
"menu.item.generateSitemap": "Site rendern",
|
||||||
|
"menu.item.about": "Über Blogging Desktop Server",
|
||||||
|
"menu.item.openDocumentation": "Dokumentation öffnen",
|
||||||
|
"menu.item.viewOnGitHub": "Auf GitHub ansehen",
|
||||||
|
"menu.item.reportIssue": "Problem melden",
|
||||||
|
"render.archive": "Archiv",
|
||||||
|
"render.pagination.label": "Seitennummerierung",
|
||||||
|
"render.pagination.newer": "neuer",
|
||||||
|
"render.pagination.older": "älter",
|
||||||
|
"render.notFound.message": "Die angeforderte Vorschauseite konnte nicht gefunden werden.",
|
||||||
|
"render.notFound.back": "Zurück zur Vorschau-Startseite",
|
||||||
|
"render.photoArchive.empty": "Keine Fotos für dieses Archiv gefunden.",
|
||||||
|
"render.gallery.empty": "Keine verknüpften Bilder gefunden.",
|
||||||
|
"render.tagCloud.empty": "Keine Tags gefunden.",
|
||||||
|
"render.tagCloud.ariaLabel": "Tag-Wolke",
|
||||||
|
"render.video.youtubeTitle": "YouTube-Video",
|
||||||
|
"render.video.vimeoTitle": "Vimeo-Video",
|
||||||
|
"render.month.1": "Januar",
|
||||||
|
"render.month.2": "Februar",
|
||||||
|
"render.month.3": "März",
|
||||||
|
"render.month.4": "Apr.",
|
||||||
|
"render.month.5": "Mai",
|
||||||
|
"render.month.6": "Juni",
|
||||||
|
"render.month.7": "Juli",
|
||||||
|
"render.month.8": "Aug.",
|
||||||
|
"render.month.9": "Sept.",
|
||||||
|
"render.month.10": "Oktober",
|
||||||
|
"render.month.11": "Nov.",
|
||||||
|
"render.month.12": "Dezember"
|
||||||
|
}
|
||||||
67
src/main/shared/i18n/locales/en.json
Normal file
67
src/main/shared/i18n/locales/en.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"menu.group.file": "File",
|
||||||
|
"menu.group.edit": "Edit",
|
||||||
|
"menu.group.view": "View",
|
||||||
|
"menu.group.blog": "Blog",
|
||||||
|
"menu.group.help": "Help",
|
||||||
|
"menu.item.newPost": "New Post",
|
||||||
|
"menu.item.importMedia": "Import Media...",
|
||||||
|
"menu.item.save": "Save",
|
||||||
|
"menu.item.openInBrowser": "Open in Browser",
|
||||||
|
"menu.item.openDataFolder": "Open Data Folder",
|
||||||
|
"menu.item.quit": "Quit",
|
||||||
|
"menu.item.undo": "Undo",
|
||||||
|
"menu.item.redo": "Redo",
|
||||||
|
"menu.item.cut": "Cut",
|
||||||
|
"menu.item.copy": "Copy",
|
||||||
|
"menu.item.paste": "Paste",
|
||||||
|
"menu.item.delete": "Delete",
|
||||||
|
"menu.item.selectAll": "Select All",
|
||||||
|
"menu.item.find": "Find",
|
||||||
|
"menu.item.replace": "Replace",
|
||||||
|
"menu.item.viewPosts": "Posts",
|
||||||
|
"menu.item.viewMedia": "Media",
|
||||||
|
"menu.item.toggleSidebar": "Toggle Sidebar",
|
||||||
|
"menu.item.togglePanel": "Toggle Panel",
|
||||||
|
"menu.item.toggleDevTools": "Toggle Developer Tools",
|
||||||
|
"menu.item.reload": "Reload",
|
||||||
|
"menu.item.forceReload": "Force Reload",
|
||||||
|
"menu.item.resetZoom": "Actual Size",
|
||||||
|
"menu.item.zoomIn": "Zoom In",
|
||||||
|
"menu.item.zoomOut": "Zoom Out",
|
||||||
|
"menu.item.toggleFullScreen": "Toggle Full Screen",
|
||||||
|
"menu.item.publishSelected": "Publish Selected",
|
||||||
|
"menu.item.previewPost": "Preview Post",
|
||||||
|
"menu.item.rebuildDatabase": "Rebuild Database from Files",
|
||||||
|
"menu.item.reindexText": "Reindex Search Text",
|
||||||
|
"menu.item.metadataDiff": "Metadata Diff Tool",
|
||||||
|
"menu.item.generateSitemap": "Render Site",
|
||||||
|
"menu.item.about": "About Blogging Desktop Server",
|
||||||
|
"menu.item.openDocumentation": "Open Documentation",
|
||||||
|
"menu.item.viewOnGitHub": "View on GitHub",
|
||||||
|
"menu.item.reportIssue": "Report Issue",
|
||||||
|
"render.archive": "Archive",
|
||||||
|
"render.pagination.label": "Pagination",
|
||||||
|
"render.pagination.newer": "newer",
|
||||||
|
"render.pagination.older": "older",
|
||||||
|
"render.notFound.message": "The requested preview page could not be found.",
|
||||||
|
"render.notFound.back": "Back to preview home",
|
||||||
|
"render.photoArchive.empty": "No photos found for this archive.",
|
||||||
|
"render.gallery.empty": "No linked images found.",
|
||||||
|
"render.tagCloud.empty": "No tags found.",
|
||||||
|
"render.tagCloud.ariaLabel": "Tag cloud",
|
||||||
|
"render.video.youtubeTitle": "YouTube video",
|
||||||
|
"render.video.vimeoTitle": "Vimeo video",
|
||||||
|
"render.month.1": "January",
|
||||||
|
"render.month.2": "February",
|
||||||
|
"render.month.3": "March",
|
||||||
|
"render.month.4": "April",
|
||||||
|
"render.month.5": "May",
|
||||||
|
"render.month.6": "June",
|
||||||
|
"render.month.7": "July",
|
||||||
|
"render.month.8": "August",
|
||||||
|
"render.month.9": "September",
|
||||||
|
"render.month.10": "October",
|
||||||
|
"render.month.11": "November",
|
||||||
|
"render.month.12": "December"
|
||||||
|
}
|
||||||
67
src/main/shared/i18n/locales/es.json
Normal file
67
src/main/shared/i18n/locales/es.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"menu.group.file": "Archivo",
|
||||||
|
"menu.group.edit": "Editar",
|
||||||
|
"menu.group.view": "Ver",
|
||||||
|
"menu.group.blog": "Bitácora",
|
||||||
|
"menu.group.help": "Ayuda",
|
||||||
|
"menu.item.newPost": "Nueva entrada",
|
||||||
|
"menu.item.importMedia": "Importar medios...",
|
||||||
|
"menu.item.save": "Guardar",
|
||||||
|
"menu.item.openInBrowser": "Abrir en el navegador",
|
||||||
|
"menu.item.openDataFolder": "Abrir carpeta de datos",
|
||||||
|
"menu.item.quit": "Salir",
|
||||||
|
"menu.item.undo": "Deshacer",
|
||||||
|
"menu.item.redo": "Rehacer",
|
||||||
|
"menu.item.cut": "Cortar",
|
||||||
|
"menu.item.copy": "Copiar",
|
||||||
|
"menu.item.paste": "Pegar",
|
||||||
|
"menu.item.delete": "Eliminar",
|
||||||
|
"menu.item.selectAll": "Seleccionar todo",
|
||||||
|
"menu.item.find": "Buscar",
|
||||||
|
"menu.item.replace": "Reemplazar",
|
||||||
|
"menu.item.viewPosts": "Entradas",
|
||||||
|
"menu.item.viewMedia": "Medios",
|
||||||
|
"menu.item.toggleSidebar": "Alternar barra lateral",
|
||||||
|
"menu.item.togglePanel": "Alternar panel",
|
||||||
|
"menu.item.toggleDevTools": "Alternar herramientas de desarrollo",
|
||||||
|
"menu.item.reload": "Recargar",
|
||||||
|
"menu.item.forceReload": "Forzar recarga",
|
||||||
|
"menu.item.resetZoom": "Tamaño real",
|
||||||
|
"menu.item.zoomIn": "Acercar",
|
||||||
|
"menu.item.zoomOut": "Alejar",
|
||||||
|
"menu.item.toggleFullScreen": "Alternar pantalla completa",
|
||||||
|
"menu.item.publishSelected": "Publicar selección",
|
||||||
|
"menu.item.previewPost": "Vista previa de entrada",
|
||||||
|
"menu.item.rebuildDatabase": "Reconstruir Database from Files",
|
||||||
|
"menu.item.reindexText": "Reindex Buscar Text",
|
||||||
|
"menu.item.metadataDiff": "Herramienta diff de metadatos",
|
||||||
|
"menu.item.generateSitemap": "Renderizar sitio",
|
||||||
|
"menu.item.about": "Acerca de Blogging Desktop Server",
|
||||||
|
"menu.item.openDocumentation": "Abrir documentación",
|
||||||
|
"menu.item.viewOnGitHub": "Ver en GitHub",
|
||||||
|
"menu.item.reportIssue": "Reportar problema",
|
||||||
|
"render.archive": "Archivo",
|
||||||
|
"render.pagination.label": "Paginación",
|
||||||
|
"render.pagination.newer": "más reciente",
|
||||||
|
"render.pagination.older": "más antiguo",
|
||||||
|
"render.notFound.message": "No se pudo encontrar la página de vista previa solicitada.",
|
||||||
|
"render.notFound.back": "Volver al inicio de vista previa",
|
||||||
|
"render.photoArchive.empty": "No se encontraron fotos para este archivo.",
|
||||||
|
"render.gallery.empty": "No se encontraron imágenes vinculadas.",
|
||||||
|
"render.tagCloud.empty": "No se encontraron etiquetas.",
|
||||||
|
"render.tagCloud.ariaLabel": "Nube de etiquetas",
|
||||||
|
"render.video.youtubeTitle": "Vídeo de YouTube",
|
||||||
|
"render.video.vimeoTitle": "Vídeo de Vimeo",
|
||||||
|
"render.month.1": "enero",
|
||||||
|
"render.month.2": "febrero",
|
||||||
|
"render.month.3": "marzo",
|
||||||
|
"render.month.4": "abril",
|
||||||
|
"render.month.5": "mayo",
|
||||||
|
"render.month.6": "junio",
|
||||||
|
"render.month.7": "julio",
|
||||||
|
"render.month.8": "agosto",
|
||||||
|
"render.month.9": "septiembre",
|
||||||
|
"render.month.10": "octubre",
|
||||||
|
"render.month.11": "noviembre",
|
||||||
|
"render.month.12": "diciembre"
|
||||||
|
}
|
||||||
67
src/main/shared/i18n/locales/fr.json
Normal file
67
src/main/shared/i18n/locales/fr.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"menu.group.file": "Fichier",
|
||||||
|
"menu.group.edit": "Édition",
|
||||||
|
"menu.group.view": "Affichage",
|
||||||
|
"menu.group.blog": "Espace blog",
|
||||||
|
"menu.group.help": "Aide",
|
||||||
|
"menu.item.newPost": "Nouvel article",
|
||||||
|
"menu.item.importMedia": "Importer des médias...",
|
||||||
|
"menu.item.save": "Enregistrer",
|
||||||
|
"menu.item.openInBrowser": "Ouvrir dans le navigateur",
|
||||||
|
"menu.item.openDataFolder": "Ouvrir le dossier de données",
|
||||||
|
"menu.item.quit": "Quitter",
|
||||||
|
"menu.item.undo": "Annuler",
|
||||||
|
"menu.item.redo": "Rétablir",
|
||||||
|
"menu.item.cut": "Couper",
|
||||||
|
"menu.item.copy": "Copier",
|
||||||
|
"menu.item.paste": "Coller",
|
||||||
|
"menu.item.delete": "Supprimer",
|
||||||
|
"menu.item.selectAll": "Tout sélectionner",
|
||||||
|
"menu.item.find": "Rechercher",
|
||||||
|
"menu.item.replace": "Remplacer",
|
||||||
|
"menu.item.viewPosts": "Articles",
|
||||||
|
"menu.item.viewMedia": "Médias",
|
||||||
|
"menu.item.toggleSidebar": "Basculer la barre latérale",
|
||||||
|
"menu.item.togglePanel": "Basculer le panneau",
|
||||||
|
"menu.item.toggleDevTools": "Basculer les outils de développement",
|
||||||
|
"menu.item.reload": "Recharger",
|
||||||
|
"menu.item.forceReload": "Forcer le rechargement",
|
||||||
|
"menu.item.resetZoom": "Taille réelle",
|
||||||
|
"menu.item.zoomIn": "Zoom avant",
|
||||||
|
"menu.item.zoomOut": "Zoom arrière",
|
||||||
|
"menu.item.toggleFullScreen": "Basculer en plein écran",
|
||||||
|
"menu.item.publishSelected": "Publier la sélection",
|
||||||
|
"menu.item.previewPost": "Aperçu de l’article",
|
||||||
|
"menu.item.rebuildDatabase": "Reconstruire Database from Files",
|
||||||
|
"menu.item.reindexText": "Reindex Recherche Text",
|
||||||
|
"menu.item.metadataDiff": "Outil de diff des métadonnées",
|
||||||
|
"menu.item.generateSitemap": "Rendre le site",
|
||||||
|
"menu.item.about": "À propos de Blogging Desktop Server",
|
||||||
|
"menu.item.openDocumentation": "Ouvrir la documentation",
|
||||||
|
"menu.item.viewOnGitHub": "Voir sur GitHub",
|
||||||
|
"menu.item.reportIssue": "Signaler un problème",
|
||||||
|
"render.archive": "Archives",
|
||||||
|
"render.pagination.label": "Navigation paginée",
|
||||||
|
"render.pagination.newer": "plus récent",
|
||||||
|
"render.pagination.older": "plus ancien",
|
||||||
|
"render.notFound.message": "La page d’aperçu demandée est introuvable.",
|
||||||
|
"render.notFound.back": "Retour à l’accueil de l’aperçu",
|
||||||
|
"render.photoArchive.empty": "Aucune photo trouvée pour cette archive.",
|
||||||
|
"render.gallery.empty": "Aucune image liée trouvée.",
|
||||||
|
"render.tagCloud.empty": "Aucun tag trouvé.",
|
||||||
|
"render.tagCloud.ariaLabel": "Nuage de tags",
|
||||||
|
"render.video.youtubeTitle": "Vidéo YouTube",
|
||||||
|
"render.video.vimeoTitle": "Vidéo Vimeo",
|
||||||
|
"render.month.1": "janvier",
|
||||||
|
"render.month.2": "février",
|
||||||
|
"render.month.3": "mars",
|
||||||
|
"render.month.4": "avril",
|
||||||
|
"render.month.5": "mai",
|
||||||
|
"render.month.6": "juin",
|
||||||
|
"render.month.7": "juillet",
|
||||||
|
"render.month.8": "août",
|
||||||
|
"render.month.9": "septembre",
|
||||||
|
"render.month.10": "octobre",
|
||||||
|
"render.month.11": "novembre",
|
||||||
|
"render.month.12": "décembre"
|
||||||
|
}
|
||||||
67
src/main/shared/i18n/locales/it.json
Normal file
67
src/main/shared/i18n/locales/it.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"menu.group.file": "Archivio",
|
||||||
|
"menu.group.edit": "Modifica",
|
||||||
|
"menu.group.view": "Vista",
|
||||||
|
"menu.group.blog": "Sezione blog",
|
||||||
|
"menu.group.help": "Aiuto",
|
||||||
|
"menu.item.newPost": "Nuovo post",
|
||||||
|
"menu.item.importMedia": "Importa media...",
|
||||||
|
"menu.item.save": "Salva",
|
||||||
|
"menu.item.openInBrowser": "Apri nel browser",
|
||||||
|
"menu.item.openDataFolder": "Apri cartella dati",
|
||||||
|
"menu.item.quit": "Esci",
|
||||||
|
"menu.item.undo": "Annulla",
|
||||||
|
"menu.item.redo": "Ripeti",
|
||||||
|
"menu.item.cut": "Taglia",
|
||||||
|
"menu.item.copy": "Copia",
|
||||||
|
"menu.item.paste": "Incolla",
|
||||||
|
"menu.item.delete": "Elimina",
|
||||||
|
"menu.item.selectAll": "Seleziona tutto",
|
||||||
|
"menu.item.find": "Trova",
|
||||||
|
"menu.item.replace": "Sostituisci",
|
||||||
|
"menu.item.viewPosts": "Post",
|
||||||
|
"menu.item.viewMedia": "Contenuti media",
|
||||||
|
"menu.item.toggleSidebar": "Attiva/disattiva barra laterale",
|
||||||
|
"menu.item.togglePanel": "Attiva/disattiva pannello",
|
||||||
|
"menu.item.toggleDevTools": "Attiva/disattiva strumenti sviluppatore",
|
||||||
|
"menu.item.reload": "Ricarica",
|
||||||
|
"menu.item.forceReload": "Forza ricaricamento",
|
||||||
|
"menu.item.resetZoom": "Dimensione reale",
|
||||||
|
"menu.item.zoomIn": "Ingrandisci",
|
||||||
|
"menu.item.zoomOut": "Riduci",
|
||||||
|
"menu.item.toggleFullScreen": "Attiva/disattiva schermo intero",
|
||||||
|
"menu.item.publishSelected": "Pubblica selezionato",
|
||||||
|
"menu.item.previewPost": "Anteprima post",
|
||||||
|
"menu.item.rebuildDatabase": "Ricostruisci Database from Files",
|
||||||
|
"menu.item.reindexText": "Reindex Ricerca Text",
|
||||||
|
"menu.item.metadataDiff": "Strumento diff metadati",
|
||||||
|
"menu.item.generateSitemap": "Renderizza sito",
|
||||||
|
"menu.item.about": "Informazioni su Blogging Desktop Server",
|
||||||
|
"menu.item.openDocumentation": "Apri documentazione",
|
||||||
|
"menu.item.viewOnGitHub": "Visualizza su GitHub",
|
||||||
|
"menu.item.reportIssue": "Segnala problema",
|
||||||
|
"render.archive": "Archivio",
|
||||||
|
"render.pagination.label": "Paginazione",
|
||||||
|
"render.pagination.newer": "più recente",
|
||||||
|
"render.pagination.older": "più vecchio",
|
||||||
|
"render.notFound.message": "La pagina di anteprima richiesta non è stata trovata.",
|
||||||
|
"render.notFound.back": "Torna alla home di anteprima",
|
||||||
|
"render.photoArchive.empty": "Nessuna foto trovata per questo archivio.",
|
||||||
|
"render.gallery.empty": "Nessuna immagine collegata trovata.",
|
||||||
|
"render.tagCloud.empty": "Nessun tag trovato.",
|
||||||
|
"render.tagCloud.ariaLabel": "Nuvola di tag",
|
||||||
|
"render.video.youtubeTitle": "Video YouTube",
|
||||||
|
"render.video.vimeoTitle": "Video Vimeo",
|
||||||
|
"render.month.1": "gennaio",
|
||||||
|
"render.month.2": "febbraio",
|
||||||
|
"render.month.3": "marzo",
|
||||||
|
"render.month.4": "aprile",
|
||||||
|
"render.month.5": "maggio",
|
||||||
|
"render.month.6": "giugno",
|
||||||
|
"render.month.7": "luglio",
|
||||||
|
"render.month.8": "agosto",
|
||||||
|
"render.month.9": "settembre",
|
||||||
|
"render.month.10": "ottobre",
|
||||||
|
"render.month.11": "novembre",
|
||||||
|
"render.month.12": "dicembre"
|
||||||
|
}
|
||||||
@@ -61,76 +61,76 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
|
|||||||
{
|
{
|
||||||
label: 'File',
|
label: 'File',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'New Post', action: 'newPost', accelerator: 'CmdOrCtrl+N' },
|
{ label: 'menu.item.newPost', action: 'newPost', accelerator: 'CmdOrCtrl+N' },
|
||||||
{ label: 'Import Media...', action: 'importMedia', accelerator: 'CmdOrCtrl+I' },
|
{ label: 'menu.item.importMedia', action: 'importMedia', accelerator: 'CmdOrCtrl+I' },
|
||||||
{ label: '', action: 'file-separator-0', separator: true },
|
{ label: '', action: 'file-separator-0', separator: true },
|
||||||
{ label: 'Save', action: 'save', accelerator: 'CmdOrCtrl+S' },
|
{ label: 'menu.item.save', action: 'save', accelerator: 'CmdOrCtrl+S' },
|
||||||
{ label: '', action: 'file-separator-1', separator: true },
|
{ label: '', action: 'file-separator-1', separator: true },
|
||||||
{ label: 'Open in Browser', action: 'openInBrowser' },
|
{ label: 'menu.item.openInBrowser', action: 'openInBrowser' },
|
||||||
{ label: '', action: 'file-separator-2', separator: true },
|
{ label: '', action: 'file-separator-2', separator: true },
|
||||||
{ label: 'Open Data Folder', action: 'openDataFolder' },
|
{ label: 'menu.item.openDataFolder', action: 'openDataFolder' },
|
||||||
{ label: '', action: 'file-separator-3', separator: true },
|
{ label: '', action: 'file-separator-3', separator: true },
|
||||||
{ label: 'Quit', action: 'quit', accelerator: 'CmdOrCtrl+Q' },
|
{ label: 'menu.item.quit', action: 'quit', accelerator: 'CmdOrCtrl+Q' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Undo', action: 'undo', accelerator: 'CmdOrCtrl+Z', role: 'undo' },
|
{ label: 'menu.item.undo', action: 'undo', accelerator: 'CmdOrCtrl+Z', role: 'undo' },
|
||||||
{ label: 'Redo', action: 'redo', accelerator: 'CmdOrCtrl+Y', role: 'redo' },
|
{ label: 'menu.item.redo', action: 'redo', accelerator: 'CmdOrCtrl+Y', role: 'redo' },
|
||||||
{ label: '', action: 'edit-separator-1', separator: true },
|
{ label: '', action: 'edit-separator-1', separator: true },
|
||||||
{ label: 'Cut', action: 'cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
|
{ label: 'menu.item.cut', action: 'cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
|
||||||
{ label: 'Copy', action: 'copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
|
{ label: 'menu.item.copy', action: 'copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
|
||||||
{ label: 'Paste', action: 'paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
|
{ label: 'menu.item.paste', action: 'paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
|
||||||
{ label: 'Delete', action: 'delete', role: 'delete' },
|
{ label: 'menu.item.delete', action: 'delete', role: 'delete' },
|
||||||
{ label: '', action: 'edit-separator-2', separator: true },
|
{ label: '', action: 'edit-separator-2', separator: true },
|
||||||
{ label: 'Select All', action: 'selectAll', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
|
{ label: 'menu.item.selectAll', action: 'selectAll', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
|
||||||
{ label: '', action: 'edit-separator-3', separator: true },
|
{ label: '', action: 'edit-separator-3', separator: true },
|
||||||
{ label: 'Find', action: 'find', accelerator: 'CmdOrCtrl+F' },
|
{ label: 'menu.item.find', action: 'find', accelerator: 'CmdOrCtrl+F' },
|
||||||
{ label: 'Replace', action: 'replace', accelerator: 'CmdOrCtrl+H' },
|
{ label: 'menu.item.replace', action: 'replace', accelerator: 'CmdOrCtrl+H' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'View',
|
label: 'View',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Posts', action: 'viewPosts', accelerator: 'CmdOrCtrl+1' },
|
{ label: 'menu.item.viewPosts', action: 'viewPosts', accelerator: 'CmdOrCtrl+1' },
|
||||||
{ label: 'Media', action: 'viewMedia', accelerator: 'CmdOrCtrl+2' },
|
{ label: 'menu.item.viewMedia', action: 'viewMedia', accelerator: 'CmdOrCtrl+2' },
|
||||||
{ label: 'Toggle Sidebar', action: 'toggleSidebar', accelerator: 'CmdOrCtrl+B' },
|
{ label: 'menu.item.toggleSidebar', action: 'toggleSidebar', accelerator: 'CmdOrCtrl+B' },
|
||||||
{ label: 'Toggle Panel', action: 'togglePanel', accelerator: 'CmdOrCtrl+J' },
|
{ label: 'menu.item.togglePanel', action: 'togglePanel', accelerator: 'CmdOrCtrl+J' },
|
||||||
{ label: 'Toggle Developer Tools', action: 'toggleDevTools', accelerator: 'CmdOrCtrl+Shift+I' },
|
{ label: 'menu.item.toggleDevTools', action: 'toggleDevTools', accelerator: 'CmdOrCtrl+Shift+I' },
|
||||||
{ label: '', action: 'view-separator-1', separator: true },
|
{ label: '', action: 'view-separator-1', separator: true },
|
||||||
{ label: 'Reload', action: 'reload' },
|
{ label: 'menu.item.reload', action: 'reload' },
|
||||||
{ label: 'Force Reload', action: 'forceReload' },
|
{ label: 'menu.item.forceReload', action: 'forceReload' },
|
||||||
{ label: '', action: 'view-separator-2', separator: true },
|
{ label: '', action: 'view-separator-2', separator: true },
|
||||||
{ label: 'Actual Size', action: 'resetZoom' },
|
{ label: 'menu.item.resetZoom', action: 'resetZoom' },
|
||||||
{ label: 'Zoom In', action: 'zoomIn' },
|
{ label: 'menu.item.zoomIn', action: 'zoomIn' },
|
||||||
{ label: 'Zoom Out', action: 'zoomOut' },
|
{ label: 'menu.item.zoomOut', action: 'zoomOut' },
|
||||||
{ label: '', action: 'view-separator-3', separator: true },
|
{ label: '', action: 'view-separator-3', separator: true },
|
||||||
{ label: 'Toggle Full Screen', action: 'toggleFullScreen' },
|
{ label: 'menu.item.toggleFullScreen', action: 'toggleFullScreen' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Blog',
|
label: 'Blog',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Publish Selected', action: 'publishSelected', accelerator: 'CmdOrCtrl+Shift+P' },
|
{ label: 'menu.item.publishSelected', action: 'publishSelected', accelerator: 'CmdOrCtrl+Shift+P' },
|
||||||
{ label: '', action: 'blog-separator-1', separator: true },
|
{ label: '', action: 'blog-separator-1', separator: true },
|
||||||
{ label: 'Preview Post', action: 'previewPost', id: APP_MENU_ITEM_IDS.previewPost, enabled: false, accelerator: 'CmdOrCtrl+Shift+V' },
|
{ label: 'menu.item.previewPost', action: 'previewPost', id: APP_MENU_ITEM_IDS.previewPost, enabled: false, accelerator: 'CmdOrCtrl+Shift+V' },
|
||||||
{ label: '', action: 'blog-separator-2', separator: true },
|
{ label: '', action: 'blog-separator-2', separator: true },
|
||||||
{ label: 'Rebuild Database from Files', action: 'rebuildDatabase' },
|
{ label: 'menu.item.rebuildDatabase', action: 'rebuildDatabase' },
|
||||||
{ label: 'Reindex Search Text', action: 'reindexText' },
|
{ label: 'menu.item.reindexText', action: 'reindexText' },
|
||||||
{ label: '', action: 'blog-separator-3', separator: true },
|
{ label: '', action: 'blog-separator-3', separator: true },
|
||||||
{ label: 'Metadata Diff Tool', action: 'metadataDiff' },
|
{ label: 'menu.item.metadataDiff', action: 'metadataDiff' },
|
||||||
{ label: 'Render Site', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
|
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Help',
|
label: 'Help',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'About Blogging Desktop Server', action: 'about' },
|
{ label: 'menu.item.about', action: 'about' },
|
||||||
{ label: 'Open Documentation', action: 'openDocumentation' },
|
{ label: 'menu.item.openDocumentation', action: 'openDocumentation' },
|
||||||
{ label: '', action: 'help-separator-1', separator: true },
|
{ label: '', action: 'help-separator-1', separator: true },
|
||||||
{ label: 'View on GitHub', action: 'viewOnGitHub' },
|
{ label: 'menu.item.viewOnGitHub', action: 'viewOnGitHub' },
|
||||||
{ label: 'Report Issue', action: 'reportIssue' },
|
{ label: 'menu.item.reportIssue', action: 'reportIssue' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer,
|
|||||||
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
||||||
import { loadTabsForProject, saveTabsForProject } from './utils';
|
import { loadTabsForProject, saveTabsForProject } from './utils';
|
||||||
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
|
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
|
||||||
|
import { useI18n } from './i18n';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
const {
|
const {
|
||||||
setPosts,
|
setPosts,
|
||||||
setMedia,
|
setMedia,
|
||||||
@@ -93,7 +95,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
window.addEventListener('beforeunload', saveTabsOnUnload);
|
window.addEventListener('beforeunload', saveTabsOnUnload);
|
||||||
return () => window.removeEventListener('beforeunload', saveTabsOnUnload);
|
return () => window.removeEventListener('beforeunload', saveTabsOnUnload);
|
||||||
}, []);
|
}, [tr]);
|
||||||
|
|
||||||
// Set up event listeners for real-time updates
|
// Set up event listeners for real-time updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -166,7 +168,7 @@ const App: React.FC = () => {
|
|||||||
window.electronAPI?.on('task:completed', (task: unknown) => {
|
window.electronAPI?.on('task:completed', (task: unknown) => {
|
||||||
const t = task as TaskProgress;
|
const t = task as TaskProgress;
|
||||||
updateTask(t.taskId, t);
|
updateTask(t.taskId, t);
|
||||||
showToast.success(`Task completed: ${t.message}`);
|
showToast.success(tr('app.taskCompleted', { message: t.message }));
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -174,7 +176,7 @@ const App: React.FC = () => {
|
|||||||
window.electronAPI?.on('task:failed', (task: unknown) => {
|
window.electronAPI?.on('task:failed', (task: unknown) => {
|
||||||
const t = task as TaskProgress;
|
const t = task as TaskProgress;
|
||||||
updateTask(t.taskId, t);
|
updateTask(t.taskId, t);
|
||||||
showToast.error(`Task failed: ${t.error || t.message}`);
|
showToast.error(tr('app.taskFailed', { message: t.error || t.message }));
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -265,7 +267,7 @@ const App: React.FC = () => {
|
|||||||
await window.electronAPI?.media.regenerateMissingThumbnails();
|
await window.electronAPI?.media.regenerateMissingThumbnails();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Database rebuild failed:', error);
|
console.error('Database rebuild failed:', error);
|
||||||
showToast.error('Database rebuild failed');
|
showToast.error(tr('app.databaseRebuildFailed'));
|
||||||
}
|
}
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
@@ -279,7 +281,7 @@ const App: React.FC = () => {
|
|||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Text reindex failed:', error);
|
console.error('Text reindex failed:', error);
|
||||||
showToast.error('Text reindex failed');
|
showToast.error(tr('app.textReindexFailed'));
|
||||||
}
|
}
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
@@ -287,7 +289,7 @@ const App: React.FC = () => {
|
|||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
window.electronAPI?.on('menu:metadataDiff', () => {
|
window.electronAPI?.on('menu:metadataDiff', () => {
|
||||||
// Open metadata diff tool tab
|
// Open metadata diff tool tab
|
||||||
openTab({ id: 'metadata-diff', type: 'metadata-diff', title: 'Metadata Diff' });
|
openTab({ id: 'metadata-diff', type: 'metadata-diff', title: tr('app.metadataDiff') });
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -297,7 +299,7 @@ const App: React.FC = () => {
|
|||||||
await window.electronAPI?.blog.generateSitemap();
|
await window.electronAPI?.blog.generateSitemap();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sitemap generation failed:', error);
|
console.error('Sitemap generation failed:', error);
|
||||||
showToast.error('Sitemap generation failed');
|
showToast.error(tr('app.sitemapGenerationFailed'));
|
||||||
}
|
}
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
@@ -316,7 +318,7 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to open selected post preview:', error);
|
console.error('Failed to open selected post preview:', error);
|
||||||
showToast.error('Failed to open selected post preview');
|
showToast.error(tr('app.previewOpenFailed'));
|
||||||
}
|
}
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
@@ -351,7 +353,7 @@ const App: React.FC = () => {
|
|||||||
const importedCount = data.posts.imported + data.pages.imported;
|
const importedCount = data.posts.imported + data.pages.imported;
|
||||||
const importedMedia = data.media.imported;
|
const importedMedia = data.media.imported;
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast.success(`Import complete: ${importedCount} posts, ${importedMedia} media files`);
|
showToast.success(tr('app.importComplete', { posts: importedCount, media: importedMedia }));
|
||||||
}
|
}
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './AISuggestionsModal.css';
|
import './AISuggestionsModal.css';
|
||||||
|
|
||||||
export interface AISuggestions {
|
export interface AISuggestions {
|
||||||
@@ -21,9 +22,9 @@ interface SuggestionFieldConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SUGGESTION_FIELDS: SuggestionFieldConfig[] = [
|
const SUGGESTION_FIELDS: SuggestionFieldConfig[] = [
|
||||||
{ key: 'title', label: 'Title' },
|
{ key: 'title', label: 'aiSuggestions.titleField' },
|
||||||
{ key: 'alt', label: 'Alt Text' },
|
{ key: 'alt', label: 'aiSuggestions.altField' },
|
||||||
{ key: 'caption', label: 'Caption' },
|
{ key: 'caption', label: 'aiSuggestions.captionField' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface AISuggestionsModalProps {
|
interface AISuggestionsModalProps {
|
||||||
@@ -45,6 +46,7 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
// Checkbox state - initialized based on whether current values are empty
|
// Checkbox state - initialized based on whether current values are empty
|
||||||
const [useTitle, setUseTitle] = useState(false);
|
const [useTitle, setUseTitle] = useState(false);
|
||||||
const [useAlt, setUseAlt] = useState(false);
|
const [useAlt, setUseAlt] = useState(false);
|
||||||
@@ -107,15 +109,15 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
|||||||
<div className="ai-suggestion-label">
|
<div className="ai-suggestion-label">
|
||||||
{field.label}
|
{field.label}
|
||||||
{currentValue && (
|
{currentValue && (
|
||||||
<span className="ai-suggestion-has-value" title="This field already has a value">
|
<span className="ai-suggestion-has-value" title={tr('aiSuggestions.hasExisting')}>
|
||||||
(has existing value)
|
{tr('aiSuggestions.hasExisting')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ai-suggestion-value">{suggestedValue}</div>
|
<div className="ai-suggestion-value">{suggestedValue}</div>
|
||||||
{currentValue && (
|
{currentValue && (
|
||||||
<div className="ai-suggestion-current">
|
<div className="ai-suggestion-current">
|
||||||
Current: <em>{currentValue}</em>
|
{tr('aiSuggestions.current')}: <em>{currentValue}</em>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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-backdrop" onClick={handleBackdropClick}>
|
||||||
<div className="ai-suggestions-modal">
|
<div className="ai-suggestions-modal">
|
||||||
<div className="ai-suggestions-modal-header">
|
<div className="ai-suggestions-modal-header">
|
||||||
<h2>AI Image Analysis</h2>
|
<h2>{tr('aiSuggestions.title')}</h2>
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<button className="ai-suggestions-modal-close" onClick={onClose} title="Close">
|
<button className="ai-suggestions-modal-close" onClick={onClose} title={tr('aiSuggestions.close')}>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -139,7 +141,7 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
|||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="ai-suggestions-loading">
|
<div className="ai-suggestions-loading">
|
||||||
<div className="ai-suggestions-spinner"></div>
|
<div className="ai-suggestions-spinner"></div>
|
||||||
<p>Analyzing image...</p>
|
<p>{tr('aiSuggestions.analyzing')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -153,15 +155,15 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
|||||||
{!isLoading && !error && hasAnySuggestion && (
|
{!isLoading && !error && hasAnySuggestion && (
|
||||||
<div className="ai-suggestions-list">
|
<div className="ai-suggestions-list">
|
||||||
<p className="ai-suggestions-intro">
|
<p className="ai-suggestions-intro">
|
||||||
Select which AI-generated values to apply. Existing values are preserved by default.
|
{tr('aiSuggestions.intro')}
|
||||||
</p>
|
</p>
|
||||||
{SUGGESTION_FIELDS.map(renderSuggestionField)}
|
{SUGGESTION_FIELDS.map((field) => renderSuggestionField({ ...field, label: tr(field.label) }))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && !hasAnySuggestion && suggestions && (
|
{!isLoading && !error && !hasAnySuggestion && suggestions && (
|
||||||
<div className="ai-suggestions-empty">
|
<div className="ai-suggestions-empty">
|
||||||
No suggestions were generated for this image.
|
{tr('aiSuggestions.empty')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -169,12 +171,12 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
|||||||
<div className="ai-suggestions-modal-footer">
|
<div className="ai-suggestions-modal-footer">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<button className="button-cancel" disabled>
|
<button className="button-cancel" disabled>
|
||||||
Please wait...
|
{tr('aiSuggestions.wait')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button className="button-cancel" onClick={onClose}>
|
<button className="button-cancel" onClick={onClose}>
|
||||||
Cancel
|
{tr('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
{hasAnySuggestion && (
|
{hasAnySuggestion && (
|
||||||
<button
|
<button
|
||||||
@@ -182,7 +184,7 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
|
|||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={!hasAnySelected}
|
disabled={!hasAnySelected}
|
||||||
>
|
>
|
||||||
Apply Selected
|
{tr('aiSuggestions.applySelected')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './ActivityBar.css';
|
import './ActivityBar.css';
|
||||||
|
|
||||||
// Simple SVG icons
|
// Simple SVG icons
|
||||||
@@ -56,6 +57,7 @@ const GitIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ActivityBar: React.FC = () => {
|
export const ActivityBar: React.FC = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
const { activeView, setActiveView, sidebarVisible, toggleSidebar, openTab, tabs, activeTabId } = useAppStore();
|
const { activeView, setActiveView, sidebarVisible, toggleSidebar, openTab, tabs, activeTabId } = useAppStore();
|
||||||
|
|
||||||
// Check if settings tab is currently active
|
// Check if settings tab is currently active
|
||||||
@@ -127,42 +129,42 @@ export const ActivityBar: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
className={`activity-bar-item ${activeView === 'posts' && sidebarVisible ? 'active' : ''}`}
|
className={`activity-bar-item ${activeView === 'posts' && sidebarVisible ? 'active' : ''}`}
|
||||||
onClick={() => handleViewClick('posts')}
|
onClick={() => handleViewClick('posts')}
|
||||||
title="Posts (click again to toggle sidebar)"
|
title={`${t('activity.posts')} ${t('activity.toggleHint')}`}
|
||||||
>
|
>
|
||||||
<PostsIcon />
|
<PostsIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`activity-bar-item ${activeView === 'pages' && sidebarVisible ? 'active' : ''}`}
|
className={`activity-bar-item ${activeView === 'pages' && sidebarVisible ? 'active' : ''}`}
|
||||||
onClick={() => handleViewClick('pages')}
|
onClick={() => handleViewClick('pages')}
|
||||||
title="Pages (click again to toggle sidebar)"
|
title={`${t('activity.pages')} ${t('activity.toggleHint')}`}
|
||||||
>
|
>
|
||||||
<PagesIcon />
|
<PagesIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`activity-bar-item ${activeView === 'media' && sidebarVisible ? 'active' : ''}`}
|
className={`activity-bar-item ${activeView === 'media' && sidebarVisible ? 'active' : ''}`}
|
||||||
onClick={() => handleViewClick('media')}
|
onClick={() => handleViewClick('media')}
|
||||||
title="Media (click again to toggle sidebar)"
|
title={`${t('activity.media')} ${t('activity.toggleHint')}`}
|
||||||
>
|
>
|
||||||
<MediaIcon />
|
<MediaIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`activity-bar-item ${isTagsTabActive ? 'active' : ''}`}
|
className={`activity-bar-item ${isTagsTabActive ? 'active' : ''}`}
|
||||||
onClick={handleTagsClick}
|
onClick={handleTagsClick}
|
||||||
title="Tags"
|
title={t('activity.tags')}
|
||||||
>
|
>
|
||||||
<TagsIcon />
|
<TagsIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`activity-bar-item ${isChatActive ? 'active' : ''}`}
|
className={`activity-bar-item ${isChatActive ? 'active' : ''}`}
|
||||||
onClick={() => handleViewClick('chat')}
|
onClick={() => handleViewClick('chat')}
|
||||||
title="AI Assistant (click again to toggle sidebar)"
|
title={`${t('activity.aiAssistant')} ${t('activity.toggleHint')}`}
|
||||||
>
|
>
|
||||||
<ChatIcon />
|
<ChatIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`activity-bar-item ${isImportActive ? 'active' : ''}`}
|
className={`activity-bar-item ${isImportActive ? 'active' : ''}`}
|
||||||
onClick={handleImportClick}
|
onClick={handleImportClick}
|
||||||
title="Import (click again to toggle sidebar)"
|
title={`${t('activity.import')} ${t('activity.toggleHint')}`}
|
||||||
>
|
>
|
||||||
<ImportIcon />
|
<ImportIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -172,14 +174,14 @@ export const ActivityBar: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
className={`activity-bar-item ${isGitActive ? 'active' : ''}`}
|
className={`activity-bar-item ${isGitActive ? 'active' : ''}`}
|
||||||
onClick={() => handleViewClick('git')}
|
onClick={() => handleViewClick('git')}
|
||||||
title="Source Control (click again to toggle sidebar)"
|
title={`${t('activity.sourceControl')} ${t('activity.toggleHint')}`}
|
||||||
>
|
>
|
||||||
<GitIcon />
|
<GitIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`activity-bar-item ${isSettingsActive ? 'active' : ''}`}
|
className={`activity-bar-item ${isSettingsActive ? 'active' : ''}`}
|
||||||
onClick={handleSettingsClick}
|
onClick={handleSettingsClick}
|
||||||
title="Settings (click again to toggle sidebar)"
|
title={`${t('common.settings')} ${t('activity.toggleHint')}`}
|
||||||
>
|
>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import Markdown from 'marked-react';
|
import Markdown from 'marked-react';
|
||||||
import type { ChatMessage, ChatConversation, ChatModel } from '../../types/electron';
|
import type { ChatMessage, ChatConversation, ChatModel } from '../../types/electron';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './ChatPanel.css';
|
import './ChatPanel.css';
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
@@ -8,6 +9,7 @@ interface ChatPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
const [conversation, setConversation] = useState<ChatConversation | null>(null);
|
const [conversation, setConversation] = useState<ChatConversation | null>(null);
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
@@ -126,10 +128,10 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
setApiKeyInput('');
|
setApiKeyInput('');
|
||||||
loadData();
|
loadData();
|
||||||
} else {
|
} else {
|
||||||
setApiKeyError('Invalid API key. Please check and try again.');
|
setApiKeyError(tr('chat.apiKeyInvalid'));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setApiKeyError('Failed to validate API key.');
|
setApiKeyError(tr('chat.apiKeyValidationFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsValidating(false);
|
setIsValidating(false);
|
||||||
}
|
}
|
||||||
@@ -184,7 +186,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
id: `error-${Date.now()}`,
|
id: `error-${Date.now()}`,
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'assistant',
|
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()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, errorMessage]);
|
setMessages(prev => [...prev, errorMessage]);
|
||||||
@@ -195,7 +197,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
id: `empty-${Date.now()}`,
|
id: `empty-${Date.now()}`,
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'assistant',
|
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()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, noContentMessage]);
|
setMessages(prev => [...prev, noContentMessage]);
|
||||||
@@ -206,7 +208,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
id: `error-${Date.now()}`,
|
id: `error-${Date.now()}`,
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: 'Sorry, an error occurred while processing your message.',
|
content: tr('chat.errorGeneric'),
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, errorMessage]);
|
setMessages(prev => [...prev, errorMessage]);
|
||||||
@@ -241,7 +243,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
id: `partial-${Date.now()}`,
|
id: `partial-${Date.now()}`,
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: partialContent + '\n\n*(cancelled)*',
|
content: `${partialContent}\n\n*(${tr('chat.cancelledSuffix')})*`,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, partialMessage]);
|
setMessages(prev => [...prev, partialMessage]);
|
||||||
@@ -323,7 +325,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
<div className="chat-message-content">
|
<div className="chat-message-content">
|
||||||
<div className="chat-message-header">
|
<div className="chat-message-header">
|
||||||
<span className="chat-message-role">
|
<span className="chat-message-role">
|
||||||
{msg.role === 'user' ? 'You' : 'Assistant'}
|
{msg.role === 'user' ? tr('chat.role.you') : tr('chat.role.assistant')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{storedToolCalls.length > 0 && (
|
{storedToolCalls.length > 0 && (
|
||||||
@@ -361,13 +363,13 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="chat-panel">
|
<div className="chat-panel">
|
||||||
<div className="chat-panel-header">
|
<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>
|
||||||
<div className="chat-messages">
|
<div className="chat-messages">
|
||||||
<div className="chat-welcome">
|
<div className="chat-welcome">
|
||||||
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
|
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
|
||||||
<h2>OpenCode Zen API Key Required</h2>
|
<h2>{tr('chat.apiKeyRequiredTitle')}</h2>
|
||||||
<p>Enter your OpenCode API key to enable AI chat.</p>
|
<p>{tr('chat.apiKeyRequiredDescription')}</p>
|
||||||
<div className="api-key-form">
|
<div className="api-key-form">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -375,7 +377,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
value={apiKeyInput}
|
value={apiKeyInput}
|
||||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()}
|
onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()}
|
||||||
placeholder="Enter your API key..."
|
placeholder={tr('chat.apiKeyPlaceholder')}
|
||||||
disabled={isValidating}
|
disabled={isValidating}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -383,7 +385,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
onClick={handleApiKeySubmit}
|
onClick={handleApiKeySubmit}
|
||||||
disabled={!apiKeyInput.trim() || isValidating}
|
disabled={!apiKeyInput.trim() || isValidating}
|
||||||
>
|
>
|
||||||
{isValidating ? 'Validating...' : 'Save Key'}
|
{isValidating ? tr('chat.apiKeyValidating') : tr('chat.apiKeySave')}
|
||||||
</button>
|
</button>
|
||||||
{apiKeyError && <div className="api-key-error">{apiKeyError}</div>}
|
{apiKeyError && <div className="api-key-error">{apiKeyError}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -397,7 +399,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
<div className="chat-panel">
|
<div className="chat-panel">
|
||||||
<div className="chat-panel-header">
|
<div className="chat-panel-header">
|
||||||
<div className="chat-panel-title">
|
<div className="chat-panel-title">
|
||||||
{conversation?.title || 'New Chat'}
|
{conversation?.title || tr('chat.newChat')}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-panel-model">
|
<div className="chat-panel-model">
|
||||||
<button
|
<button
|
||||||
@@ -427,14 +429,14 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
{messages.length === 0 && !isStreaming && (
|
{messages.length === 0 && !isStreaming && (
|
||||||
<div className="chat-welcome">
|
<div className="chat-welcome">
|
||||||
<div className="chat-welcome-icon">{'\u{1F916}'}</div>
|
<div className="chat-welcome-icon">{'\u{1F916}'}</div>
|
||||||
<h2>Welcome to the AI Assistant</h2>
|
<h2>{tr('chat.welcomeTitle')}</h2>
|
||||||
<p>I can help you manage your posts and media. Try asking me to:</p>
|
<p>{tr('chat.welcomeDescription')}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Search for posts about a specific topic</li>
|
<li>{tr('chat.welcomeTipSearch')}</li>
|
||||||
<li>Get details about a specific post</li>
|
<li>{tr('chat.welcomeTipDetails')}</li>
|
||||||
<li>List all tags or categories in your blog</li>
|
<li>{tr('chat.welcomeTipTags')}</li>
|
||||||
<li>Update metadata for posts or media</li>
|
<li>{tr('chat.welcomeTipMetadata')}</li>
|
||||||
<li>List all images in your media library</li>
|
<li>{tr('chat.welcomeTipImages')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -446,7 +448,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
||||||
<div className="chat-message-content">
|
<div className="chat-message-content">
|
||||||
<div className="chat-message-header">
|
<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>
|
<span className="streaming-indicator">{'\u25CF'}</span>
|
||||||
</div>
|
</div>
|
||||||
{renderToolMarkers(toolEvents)}
|
{renderToolMarkers(toolEvents)}
|
||||||
@@ -476,7 +478,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
<div className="chat-input-container">
|
<div className="chat-input-container">
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<button className="chat-abort-button" onClick={handleAbort}>
|
<button className="chat-abort-button" onClick={handleAbort}>
|
||||||
{'\u25FC'} Stop
|
{'\u25FC'} {tr('chat.stop')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="chat-input-wrapper">
|
<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`;
|
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type a message..."
|
placeholder={tr('chat.inputPlaceholder')}
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={isStreaming}
|
disabled={isStreaming}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './ConfirmDeleteModal.css';
|
import './ConfirmDeleteModal.css';
|
||||||
|
|
||||||
export interface DeleteReference {
|
export interface DeleteReference {
|
||||||
@@ -20,6 +21,7 @@ interface ConfirmDeleteModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details, onClose }) => {
|
export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details, onClose }) => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
if (!details) return null;
|
if (!details) return null;
|
||||||
|
|
||||||
const handleConfirm = useCallback(async () => {
|
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-backdrop" onClick={handleBackdropClick}>
|
||||||
<div className="confirm-delete-modal">
|
<div className="confirm-delete-modal">
|
||||||
<div className="confirm-delete-modal-header">
|
<div className="confirm-delete-modal-header">
|
||||||
<h2>Confirm Deletion</h2>
|
<h2>{tr('confirmDelete.title')}</h2>
|
||||||
<button className="confirm-delete-modal-close" onClick={onClose} title="Close">
|
<button className="confirm-delete-modal-close" onClick={onClose} title={tr('aiSuggestions.close')}>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="confirm-delete-modal-body">
|
<div className="confirm-delete-modal-body">
|
||||||
<div className="confirm-delete-message">
|
<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>?
|
<strong>{details.itemTitle}</strong>?
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
|
|||||||
<div className="confirm-delete-warning">
|
<div className="confirm-delete-warning">
|
||||||
<div className="warning-icon">⚠️</div>
|
<div className="warning-icon">⚠️</div>
|
||||||
<div className="warning-content">
|
<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">
|
<ul className="reference-list">
|
||||||
{details.references.map((ref) => (
|
{details.references.map((ref) => (
|
||||||
<li key={ref.id}>
|
<li key={ref.id}>
|
||||||
@@ -66,7 +68,7 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="warning-note">
|
<p className="warning-note">
|
||||||
Deleting this {details.itemType} will remove all these references.
|
{tr('confirmDelete.note', { itemType: tr(`confirmDelete.itemType.${details.itemType}`) })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,10 +76,10 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
|
|||||||
</div>
|
</div>
|
||||||
<div className="confirm-delete-modal-footer">
|
<div className="confirm-delete-modal-footer">
|
||||||
<button className="button-cancel" onClick={onClose}>
|
<button className="button-cancel" onClick={onClose}>
|
||||||
Cancel
|
{tr('confirmDelete.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button className="button-delete" onClick={handleConfirm}>
|
<button className="button-delete" onClick={handleConfirm}>
|
||||||
Delete {details.itemType === 'post' ? 'Post' : 'Media'}
|
{details.itemType === 'post' ? tr('confirmDelete.deletePost') : tr('confirmDelete.deleteMedia')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './CredentialsPanel.css';
|
import './CredentialsPanel.css';
|
||||||
|
|
||||||
interface Credentials {
|
interface Credentials {
|
||||||
@@ -12,6 +13,7 @@ interface Credentials {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CredentialsPanel: React.FC = () => {
|
export const CredentialsPanel: React.FC = () => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
const [credentials, setCredentials] = useState<Credentials>({
|
const [credentials, setCredentials] = useState<Credentials>({
|
||||||
ftpHost: '',
|
ftpHost: '',
|
||||||
ftpUser: '',
|
ftpUser: '',
|
||||||
@@ -32,7 +34,7 @@ export const CredentialsPanel: React.FC = () => {
|
|||||||
setCredentials(JSON.parse(savedCreds));
|
setCredentials(JSON.parse(savedCreds));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load credentials:', error);
|
console.error(tr('credentials.error.load'), error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadCredentials();
|
loadCredentials();
|
||||||
@@ -43,10 +45,10 @@ export const CredentialsPanel: React.FC = () => {
|
|||||||
// Save to localStorage (in production, use secure storage)
|
// Save to localStorage (in production, use secure storage)
|
||||||
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
|
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
|
||||||
|
|
||||||
showToast.success('Credentials saved');
|
showToast.success(tr('credentials.toast.saved'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save credentials:', error);
|
console.error(tr('credentials.error.save'), error);
|
||||||
showToast.error('Failed to save credentials');
|
showToast.error(tr('credentials.toast.saveFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,14 +70,14 @@ export const CredentialsPanel: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTestConnection = async (type: 'ftp' | 'ssh') => {
|
const handleTestConnection = async (type: 'ftp' | 'ssh') => {
|
||||||
showToast.loading(`Testing ${type.toUpperCase()} connection...`);
|
showToast.loading(tr('credentials.toast.testing', { type: type.toUpperCase() }));
|
||||||
|
|
||||||
// Simulate connection test
|
// Simulate connection test
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
// In a real implementation, this would test the actual connection
|
// In a real implementation, this would test the actual connection
|
||||||
showToast.dismiss();
|
showToast.dismiss();
|
||||||
showToast.error('Connection failed - check credentials');
|
showToast.error(tr('credentials.toast.connectionFailed'));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -85,13 +87,13 @@ export const CredentialsPanel: React.FC = () => {
|
|||||||
className={activeTab === 'ftp' ? 'active' : ''}
|
className={activeTab === 'ftp' ? 'active' : ''}
|
||||||
onClick={() => setActiveTab('ftp')}
|
onClick={() => setActiveTab('ftp')}
|
||||||
>
|
>
|
||||||
FTP
|
{tr('credentials.tab.ftp')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={activeTab === 'ssh' ? 'active' : ''}
|
className={activeTab === 'ssh' ? 'active' : ''}
|
||||||
onClick={() => setActiveTab('ssh')}
|
onClick={() => setActiveTab('ssh')}
|
||||||
>
|
>
|
||||||
SSH
|
{tr('credentials.tab.ssh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,49 +101,49 @@ export const CredentialsPanel: React.FC = () => {
|
|||||||
{activeTab === 'ftp' && (
|
{activeTab === 'ftp' && (
|
||||||
<div className="credentials-form">
|
<div className="credentials-form">
|
||||||
<div className="credentials-header">
|
<div className="credentials-header">
|
||||||
<h4>FTP Publishing</h4>
|
<h4>{tr('credentials.ftp.title')}</h4>
|
||||||
<p className="text-muted">
|
<p className="text-muted">
|
||||||
Configure FTP for publishing your blog to a web server.
|
{tr('credentials.ftp.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="credentials-field">
|
<div className="credentials-field">
|
||||||
<label>Host</label>
|
<label>{tr('credentials.field.host')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ftp.example.com"
|
placeholder={tr('credentials.ftp.placeholder.host')}
|
||||||
value={credentials.ftpHost}
|
value={credentials.ftpHost}
|
||||||
onChange={(e) => setCredentials({ ...credentials, ftpHost: e.target.value })}
|
onChange={(e) => setCredentials({ ...credentials, ftpHost: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="credentials-field">
|
<div className="credentials-field">
|
||||||
<label>Username</label>
|
<label>{tr('credentials.field.username')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ftp-user"
|
placeholder={tr('credentials.ftp.placeholder.username')}
|
||||||
value={credentials.ftpUser}
|
value={credentials.ftpUser}
|
||||||
onChange={(e) => setCredentials({ ...credentials, ftpUser: e.target.value })}
|
onChange={(e) => setCredentials({ ...credentials, ftpUser: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="credentials-field">
|
<div className="credentials-field">
|
||||||
<label>Password</label>
|
<label>{tr('credentials.field.password')}</label>
|
||||||
<input
|
<input
|
||||||
type={showTokens ? 'text' : 'password'}
|
type={showTokens ? 'text' : 'password'}
|
||||||
placeholder="Password"
|
placeholder={tr('credentials.ftp.placeholder.password')}
|
||||||
value={credentials.ftpPassword}
|
value={credentials.ftpPassword}
|
||||||
onChange={(e) => setCredentials({ ...credentials, ftpPassword: e.target.value })}
|
onChange={(e) => setCredentials({ ...credentials, ftpPassword: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="credentials-actions">
|
<div className="credentials-actions">
|
||||||
<button onClick={handleSave}>Save</button>
|
<button onClick={handleSave}>{tr('common.save')}</button>
|
||||||
<button className="secondary" onClick={() => handleTestConnection('ftp')}>
|
<button className="secondary" onClick={() => handleTestConnection('ftp')}>
|
||||||
Test Connection
|
{tr('credentials.action.testConnection')}
|
||||||
</button>
|
</button>
|
||||||
<button className="secondary danger" onClick={() => handleClear('ftp')}>
|
<button className="secondary danger" onClick={() => handleClear('ftp')}>
|
||||||
Clear
|
{tr('common.clear')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,49 +152,49 @@ export const CredentialsPanel: React.FC = () => {
|
|||||||
{activeTab === 'ssh' && (
|
{activeTab === 'ssh' && (
|
||||||
<div className="credentials-form">
|
<div className="credentials-form">
|
||||||
<div className="credentials-header">
|
<div className="credentials-header">
|
||||||
<h4>SSH Publishing</h4>
|
<h4>{tr('credentials.ssh.title')}</h4>
|
||||||
<p className="text-muted">
|
<p className="text-muted">
|
||||||
Configure SSH for secure publishing to your server.
|
{tr('credentials.ssh.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="credentials-field">
|
<div className="credentials-field">
|
||||||
<label>Host</label>
|
<label>{tr('credentials.field.host')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="server.example.com"
|
placeholder={tr('credentials.ssh.placeholder.host')}
|
||||||
value={credentials.sshHost}
|
value={credentials.sshHost}
|
||||||
onChange={(e) => setCredentials({ ...credentials, sshHost: e.target.value })}
|
onChange={(e) => setCredentials({ ...credentials, sshHost: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="credentials-field">
|
<div className="credentials-field">
|
||||||
<label>Username</label>
|
<label>{tr('credentials.field.username')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ssh-user"
|
placeholder={tr('credentials.ssh.placeholder.username')}
|
||||||
value={credentials.sshUser}
|
value={credentials.sshUser}
|
||||||
onChange={(e) => setCredentials({ ...credentials, sshUser: e.target.value })}
|
onChange={(e) => setCredentials({ ...credentials, sshUser: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="credentials-field">
|
<div className="credentials-field">
|
||||||
<label>SSH Key Path</label>
|
<label>{tr('credentials.field.sshKeyPath')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="~/.ssh/id_rsa"
|
placeholder={tr('credentials.ssh.placeholder.keyPath')}
|
||||||
value={credentials.sshKeyPath}
|
value={credentials.sshKeyPath}
|
||||||
onChange={(e) => setCredentials({ ...credentials, sshKeyPath: e.target.value })}
|
onChange={(e) => setCredentials({ ...credentials, sshKeyPath: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="credentials-actions">
|
<div className="credentials-actions">
|
||||||
<button onClick={handleSave}>Save</button>
|
<button onClick={handleSave}>{tr('common.save')}</button>
|
||||||
<button className="secondary" onClick={() => handleTestConnection('ssh')}>
|
<button className="secondary" onClick={() => handleTestConnection('ssh')}>
|
||||||
Test Connection
|
{tr('credentials.action.testConnection')}
|
||||||
</button>
|
</button>
|
||||||
<button className="secondary danger" onClick={() => handleClear('ssh')}>
|
<button className="secondary danger" onClick={() => handleClear('ssh')}>
|
||||||
Clear
|
{tr('common.clear')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import React, { useEffect } from 'react';
|
|||||||
import Markdown from 'marked-react';
|
import Markdown from 'marked-react';
|
||||||
import documentationContent from '../../../../DOCUMENTATION.md?raw';
|
import documentationContent from '../../../../DOCUMENTATION.md?raw';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme';
|
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme';
|
||||||
import './DocumentationView.css';
|
import './DocumentationView.css';
|
||||||
|
|
||||||
export const DocumentationView: React.FC = () => {
|
export const DocumentationView: React.FC = () => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
const { picoTheme } = useAppStore();
|
const { picoTheme } = useAppStore();
|
||||||
const resolvedTheme = getRendererPicoTheme(picoTheme);
|
const resolvedTheme = getRendererPicoTheme(picoTheme);
|
||||||
|
|
||||||
@@ -18,8 +20,8 @@ export const DocumentationView: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="documentation-view">
|
<div className="documentation-view">
|
||||||
<div className="documentation-header">
|
<div className="documentation-header">
|
||||||
<h1>Documentation</h1>
|
<h1>{tr('docs.title')}</h1>
|
||||||
<p>User guide for this installed bDS version.</p>
|
<p>{tr('docs.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<main className="documentation-scroll">
|
<main className="documentation-scroll">
|
||||||
<div className="documentation-content markdown-body pico" data-theme="auto" data-pico-theme={resolvedTheme}>
|
<div className="documentation-content markdown-body pico" data-theme="auto" data-pico-theme={resolvedTheme}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './ErrorModal.css';
|
import './ErrorModal.css';
|
||||||
|
|
||||||
export interface ErrorDetails {
|
export interface ErrorDetails {
|
||||||
@@ -13,16 +14,17 @@ interface ErrorModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
|
export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
if (!error) return null;
|
if (!error) return null;
|
||||||
|
|
||||||
const handleCopyStack = useCallback(async () => {
|
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 {
|
try {
|
||||||
await navigator.clipboard.writeText(textToCopy);
|
await navigator.clipboard.writeText(textToCopy);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to copy to clipboard:', err);
|
console.error('Failed to copy to clipboard:', err);
|
||||||
}
|
}
|
||||||
}, [error]);
|
}, [error, tr]);
|
||||||
|
|
||||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||||
if (e.target === e.currentTarget) {
|
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-backdrop" onClick={handleBackdropClick}>
|
||||||
<div className="error-modal">
|
<div className="error-modal">
|
||||||
<div className="error-modal-header">
|
<div className="error-modal-header">
|
||||||
<h2>{error.title || 'Error'}</h2>
|
<h2>{error.title || tr('errorModal.error')}</h2>
|
||||||
<button className="error-modal-close" onClick={onClose} title="Close">
|
<button className="error-modal-close" onClick={onClose} title={tr('aiSuggestions.close')}>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,9 +46,9 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
|
|||||||
{error.stack && (
|
{error.stack && (
|
||||||
<div className="error-stack-section">
|
<div className="error-stack-section">
|
||||||
<div className="error-stack-header">
|
<div className="error-stack-header">
|
||||||
<span>Stack Trace</span>
|
<span>{tr('errorModal.stackTrace')}</span>
|
||||||
<button className="copy-button" onClick={handleCopyStack} title="Copy to clipboard">
|
<button className="copy-button" onClick={handleCopyStack} title={tr('errorModal.copyClipboard')}>
|
||||||
📋 Copy
|
📋 {tr('errorModal.copy')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="error-stack">{error.stack}</pre>
|
<pre className="error-stack">{error.stack}</pre>
|
||||||
@@ -54,7 +56,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="error-modal-footer">
|
<div className="error-modal-footer">
|
||||||
<button onClick={onClose}>Close</button>
|
<button onClick={onClose}>{tr('aiSuggestions.close')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { DiffEditor } from '@monaco-editor/react';
|
import { DiffEditor } from '@monaco-editor/react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './GitDiffView.css';
|
import './GitDiffView.css';
|
||||||
|
|
||||||
interface CommitFileDiff {
|
interface CommitFileDiff {
|
||||||
@@ -47,6 +48,7 @@ function toModelPath(filePath: string, side: 'original' | 'modified', scope: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
const { activeProject, gitDiffPreferences } = useAppStore();
|
const { activeProject, gitDiffPreferences } = useAppStore();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -94,7 +96,7 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!activeProject) {
|
if (!activeProject) {
|
||||||
setError('No active project selected.');
|
setError(tr('gitDiff.noProject'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +105,7 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
|||||||
: await window.electronAPI.app.getDefaultProjectPath(activeProject.id);
|
: await window.electronAPI.app.getDefaultProjectPath(activeProject.id);
|
||||||
|
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
setError('Unable to resolve project path.');
|
setError(tr('gitDiff.noProjectPath'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,20 +131,23 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
|||||||
setModified(diff.modified || '');
|
setModified(diff.modified || '');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load diff.');
|
setError(tr('gitDiff.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void loadDiff();
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="git-diff-view">
|
<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-message">Loading diff...</div>
|
<div className="git-diff-message">{tr('gitDiff.loading')}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -150,7 +155,7 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="git-diff-view">
|
<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 className="git-diff-error">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -158,27 +163,27 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-diff-view">
|
<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 && (
|
{isCommitDiff && commitFiles.length > 0 && (
|
||||||
<div className="git-diff-commit-nav">
|
<div className="git-diff-commit-nav">
|
||||||
<label htmlFor="git-diff-commit-files" className="git-diff-commit-label">
|
<label htmlFor="git-diff-commit-files" className="git-diff-commit-label">
|
||||||
Changed files
|
{tr('gitDiff.changedFiles')}
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="git-diff-commit-button"
|
className="git-diff-commit-button"
|
||||||
onClick={selectPreviousCommitFile}
|
onClick={selectPreviousCommitFile}
|
||||||
disabled={!canSelectPreviousFile}
|
disabled={!canSelectPreviousFile}
|
||||||
aria-label="Previous file"
|
aria-label={tr('gitDiff.previousFile')}
|
||||||
>
|
>
|
||||||
Previous
|
{tr('gitDiff.previousFile')}
|
||||||
</button>
|
</button>
|
||||||
<select
|
<select
|
||||||
id="git-diff-commit-files"
|
id="git-diff-commit-files"
|
||||||
className="git-diff-commit-select"
|
className="git-diff-commit-select"
|
||||||
value={selectedCommitFilePath}
|
value={selectedCommitFilePath}
|
||||||
onChange={(event) => setSelectedCommitFilePath(event.target.value)}
|
onChange={(event) => setSelectedCommitFilePath(event.target.value)}
|
||||||
aria-label="Changed files"
|
aria-label={tr('gitDiff.changedFiles')}
|
||||||
>
|
>
|
||||||
{commitFiles.map((entry) => (
|
{commitFiles.map((entry) => (
|
||||||
<option key={entry.filePath} value={entry.filePath}>
|
<option key={entry.filePath} value={entry.filePath}>
|
||||||
@@ -191,9 +196,9 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
|||||||
className="git-diff-commit-button"
|
className="git-diff-commit-button"
|
||||||
onClick={selectNextCommitFile}
|
onClick={selectNextCommitFile}
|
||||||
disabled={!canSelectNextFile}
|
disabled={!canSelectNextFile}
|
||||||
aria-label="Next file"
|
aria-label={tr('gitDiff.nextFile')}
|
||||||
>
|
>
|
||||||
Next
|
{tr('gitDiff.nextFile')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import type { GitInitProgress, GitHistoryEntry, GitRemoteStateDto } from '../../../main/shared/electronApi';
|
import type { GitInitProgress, GitHistoryEntry, GitRemoteStateDto } from '../../../main/shared/electronApi';
|
||||||
import './GitSidebar.css';
|
import './GitSidebar.css';
|
||||||
import '../Sidebar/Sidebar.css';
|
import '../Sidebar/Sidebar.css';
|
||||||
@@ -27,6 +28,7 @@ const mergeStatusFilesIncremental = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const GitSidebar: React.FC = () => {
|
export const GitSidebar: React.FC = () => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
const { activeProject, openTab, tabs, closeTab } = useAppStore();
|
const { activeProject, openTab, tabs, closeTab } = useAppStore();
|
||||||
const [projectPath, setProjectPath] = useState<string | null>(null);
|
const [projectPath, setProjectPath] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -98,7 +100,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
if (fetchFirst) {
|
if (fetchFirst) {
|
||||||
const fetchResult = await window.electronAPI.git.fetch(targetProjectPath);
|
const fetchResult = await window.electronAPI.git.fetch(targetProjectPath);
|
||||||
if (!fetchResult.success) {
|
if (!fetchResult.success) {
|
||||||
const message = fetchResult.error || 'Failed to fetch remote updates.';
|
const message = fetchResult.error || tr('gitSidebar.error.fetchRemoteUpdates');
|
||||||
setRemoteStateError(message);
|
setRemoteStateError(message);
|
||||||
if (!background) {
|
if (!background) {
|
||||||
setError(message);
|
setError(message);
|
||||||
@@ -111,7 +113,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
setRemoteState(nextRemoteState);
|
setRemoteState(nextRemoteState);
|
||||||
setRemoteStateError(null);
|
setRemoteStateError(null);
|
||||||
} catch {
|
} catch {
|
||||||
const message = 'Unable to refresh remote tracking state.';
|
const message = tr('gitSidebar.error.refreshRemoteState');
|
||||||
setRemoteStateError(message);
|
setRemoteStateError(message);
|
||||||
if (!background) {
|
if (!background) {
|
||||||
setError(message);
|
setError(message);
|
||||||
@@ -120,7 +122,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
remoteRefreshInFlightRef.current = false;
|
remoteRefreshInFlightRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[tr],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`;
|
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 => {
|
const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'prune-lfs' | 'commit'): string => {
|
||||||
if (action === 'push') {
|
if (action === 'push') {
|
||||||
return 'Pushing commits to remote... this can take a while for large uploads.';
|
return tr('gitSidebar.progress.pushingRemote');
|
||||||
}
|
}
|
||||||
if (action === 'fetch') {
|
if (action === 'fetch') {
|
||||||
return 'Fetching remote updates...';
|
return tr('gitSidebar.progress.fetching');
|
||||||
}
|
}
|
||||||
if (action === 'pull') {
|
if (action === 'pull') {
|
||||||
return 'Pulling latest changes...';
|
return tr('gitSidebar.progress.pulling');
|
||||||
}
|
}
|
||||||
if (action === 'prune-lfs') {
|
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 => {
|
const getHistoryStatusLabel = (status: GitHistoryEntry['syncStatus']): string => {
|
||||||
if (status === 'local-only') {
|
if (status === 'local-only') {
|
||||||
return 'Local only';
|
return tr('gitSidebar.history.localOnly');
|
||||||
}
|
}
|
||||||
if (status === 'remote-only') {
|
if (status === 'remote-only') {
|
||||||
return 'Remote only';
|
return tr('gitSidebar.history.remoteOnly');
|
||||||
}
|
}
|
||||||
return 'Synced';
|
return tr('gitSidebar.history.synced');
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDiffTab = useCallback(
|
const openDiffTab = useCallback(
|
||||||
@@ -194,7 +196,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const availability = await window.electronAPI.git.checkAvailability();
|
const availability = await window.electronAPI.git.checkAvailability();
|
||||||
if (!availability.gitFound) {
|
if (!availability.gitFound) {
|
||||||
setError('Git executable not found. Please install Git and restart the app.');
|
setError(tr('gitSidebar.error.gitMissing'));
|
||||||
setIsRepo(false);
|
setIsRepo(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -203,7 +205,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
setProjectPath(resolvedProjectPath);
|
setProjectPath(resolvedProjectPath);
|
||||||
|
|
||||||
if (!resolvedProjectPath) {
|
if (!resolvedProjectPath) {
|
||||||
setError('No active project selected.');
|
setError(tr('gitSidebar.error.noActiveProject'));
|
||||||
setIsRepo(false);
|
setIsRepo(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -228,7 +230,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
setRemoteStateError(null);
|
setRemoteStateError(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Unable to load repository status.');
|
setError(tr('gitSidebar.error.loadRepoStatus'));
|
||||||
setIsRepo(false);
|
setIsRepo(false);
|
||||||
setHasRemote(false);
|
setHasRemote(false);
|
||||||
setStatusFiles([]);
|
setStatusFiles([]);
|
||||||
@@ -238,7 +240,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [refreshRemoteState, refreshRepoDetails, resolveProjectPath]);
|
}, [refreshRemoteState, refreshRepoDetails, resolveProjectPath, tr]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadRepoState();
|
void loadRepoState();
|
||||||
@@ -297,7 +299,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
setInitProgress({
|
setInitProgress({
|
||||||
phase: 'initializing-repo',
|
phase: 'initializing-repo',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
message: 'Preparing repository initialization...',
|
message: tr('gitSidebar.progress.preparingInit'),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -306,13 +308,13 @@ export const GitSidebar: React.FC = () => {
|
|||||||
? await window.electronAPI.git.init(projectPath, normalizedRemoteUrl)
|
? await window.electronAPI.git.init(projectPath, normalizedRemoteUrl)
|
||||||
: await window.electronAPI.git.init(projectPath);
|
: await window.electronAPI.git.init(projectPath);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error || 'Failed to initialize git repository.');
|
setError(result.error || tr('gitSidebar.error.initFailed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadRepoState();
|
await loadRepoState();
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to initialize git repository.');
|
setError(tr('gitSidebar.error.initFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setInitializing(false);
|
setInitializing(false);
|
||||||
}
|
}
|
||||||
@@ -325,7 +327,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
|
|
||||||
const effectiveProjectPath = projectPath ?? (await resolveProjectPath());
|
const effectiveProjectPath = projectPath ?? (await resolveProjectPath());
|
||||||
if (!effectiveProjectPath) {
|
if (!effectiveProjectPath) {
|
||||||
setError('No active project selected.');
|
setError(tr('gitSidebar.error.noActiveProject'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
@@ -349,13 +351,13 @@ export const GitSidebar: React.FC = () => {
|
|||||||
recentCommitsToKeep: 2,
|
recentCommitsToKeep: 2,
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error || `Failed to ${action}.`);
|
setError(result.error || tr('gitSidebar.error.actionFailed', { action }));
|
||||||
setErrorGuidance('guidance' in result ? result.guidance || [] : []);
|
setErrorGuidance('guidance' in result ? result.guidance || [] : []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadRepoState();
|
await loadRepoState();
|
||||||
} catch {
|
} catch {
|
||||||
setError(`Failed to ${action}.`);
|
setError(tr('gitSidebar.error.actionFailed', { action }));
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -368,7 +370,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
|
|
||||||
const effectiveProjectPath = projectPath ?? (await resolveProjectPath());
|
const effectiveProjectPath = projectPath ?? (await resolveProjectPath());
|
||||||
if (!effectiveProjectPath) {
|
if (!effectiveProjectPath) {
|
||||||
setError('No active project selected.');
|
setError(tr('gitSidebar.error.noActiveProject'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
@@ -382,7 +384,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
const messageToCommit = commitMessageInputRef.current?.value ?? commitMessage;
|
const messageToCommit = commitMessageInputRef.current?.value ?? commitMessage;
|
||||||
const result = await window.electronAPI.git.commitAll(effectiveProjectPath, messageToCommit);
|
const result = await window.electronAPI.git.commitAll(effectiveProjectPath, messageToCommit);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error || 'Failed to commit changes.');
|
setError(result.error || tr('gitSidebar.error.commitFailed'));
|
||||||
setErrorGuidance(result.guidance || []);
|
setErrorGuidance(result.guidance || []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -394,7 +396,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
setCommitMessage('');
|
setCommitMessage('');
|
||||||
await loadRepoState();
|
await loadRepoState();
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to commit changes.');
|
setError(tr('gitSidebar.error.commitFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -403,8 +405,8 @@ export const GitSidebar: React.FC = () => {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="git-sidebar">
|
<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">Loading...</div>
|
<div className="git-sidebar-empty">{tr('gitSidebar.loading')}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -417,7 +419,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
onClick={() => setIsTranscriptExpanded((previous) => !previous)}
|
onClick={() => setIsTranscriptExpanded((previous) => !previous)}
|
||||||
aria-expanded={isTranscriptExpanded}
|
aria-expanded={isTranscriptExpanded}
|
||||||
>
|
>
|
||||||
Initialization transcript
|
{tr('gitSidebar.init.transcript')}
|
||||||
</button>
|
</button>
|
||||||
{isTranscriptExpanded && (
|
{isTranscriptExpanded && (
|
||||||
<ul className="git-sidebar-transcript-list">
|
<ul className="git-sidebar-transcript-list">
|
||||||
@@ -435,16 +437,16 @@ export const GitSidebar: React.FC = () => {
|
|||||||
if (isRepo) {
|
if (isRepo) {
|
||||||
return (
|
return (
|
||||||
<div className="git-sidebar">
|
<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-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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="git-sidebar-button"
|
className="git-sidebar-button"
|
||||||
onClick={() => handleRepoAction('fetch')}
|
onClick={() => handleRepoAction('fetch')}
|
||||||
disabled={actionLoading !== null}
|
disabled={actionLoading !== null}
|
||||||
>
|
>
|
||||||
{actionLoading === 'fetch' ? 'Fetching...' : 'Fetch'}
|
{actionLoading === 'fetch' ? tr('gitSidebar.action.fetching') : tr('gitSidebar.action.fetch')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -452,7 +454,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
onClick={() => handleRepoAction('pull')}
|
onClick={() => handleRepoAction('pull')}
|
||||||
disabled={actionLoading !== null}
|
disabled={actionLoading !== null}
|
||||||
>
|
>
|
||||||
{actionLoading === 'pull' ? 'Pulling...' : 'Pull'}
|
{actionLoading === 'pull' ? tr('gitSidebar.action.pulling') : tr('gitSidebar.action.pull')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -460,7 +462,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
onClick={() => handleRepoAction('push')}
|
onClick={() => handleRepoAction('push')}
|
||||||
disabled={actionLoading !== null}
|
disabled={actionLoading !== null}
|
||||||
>
|
>
|
||||||
{actionLoading === 'push' ? 'Pushing...' : 'Push'}
|
{actionLoading === 'push' ? tr('gitSidebar.action.pushing') : tr('gitSidebar.action.push')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -468,7 +470,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
onClick={() => handleRepoAction('prune-lfs')}
|
onClick={() => handleRepoAction('prune-lfs')}
|
||||||
disabled={actionLoading !== null}
|
disabled={actionLoading !== null}
|
||||||
>
|
>
|
||||||
{actionLoading === 'prune-lfs' ? 'Pruning...' : 'Prune LFS'}
|
{actionLoading === 'prune-lfs' ? tr('gitSidebar.action.pruning') : tr('gitSidebar.action.pruneLfs')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{actionLoading && (
|
{actionLoading && (
|
||||||
@@ -478,14 +480,14 @@ export const GitSidebar: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="git-sidebar-section">
|
<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">
|
<div className="git-sidebar-commit-row">
|
||||||
<input
|
<input
|
||||||
ref={commitMessageInputRef}
|
ref={commitMessageInputRef}
|
||||||
className="git-sidebar-input"
|
className="git-sidebar-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Commit message"
|
placeholder={tr('gitSidebar.placeholder.commitMessage')}
|
||||||
value={commitMessage}
|
value={commitMessage}
|
||||||
onChange={(event) => setCommitMessage(event.target.value)}
|
onChange={(event) => setCommitMessage(event.target.value)}
|
||||||
disabled={actionLoading !== null}
|
disabled={actionLoading !== null}
|
||||||
@@ -496,16 +498,16 @@ export const GitSidebar: React.FC = () => {
|
|||||||
onClick={handleCommit}
|
onClick={handleCommit}
|
||||||
disabled={actionLoading !== null}
|
disabled={actionLoading !== null}
|
||||||
>
|
>
|
||||||
{actionLoading === 'commit' ? 'Committing...' : 'Commit'}
|
{actionLoading === 'commit' ? tr('gitSidebar.action.committing') : tr('gitSidebar.action.commit')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{statusLoading ? (
|
{statusLoading ? (
|
||||||
<div className="git-sidebar-empty-state">Loading changes...</div>
|
<div className="git-sidebar-empty-state">{tr('gitSidebar.loadingChanges')}</div>
|
||||||
) : statusFiles.length === 0 ? (
|
) : 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) => (
|
{statusFiles.map((file) => (
|
||||||
<button
|
<button
|
||||||
key={file.path}
|
key={file.path}
|
||||||
@@ -524,36 +526,36 @@ export const GitSidebar: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="git-sidebar-section git-sidebar-history">
|
<div className="git-sidebar-section git-sidebar-history">
|
||||||
<div className="sidebar-section-title">Version History ({historyEntries.length})</div>
|
<div className="sidebar-section-title">{tr('gitSidebar.versionHistory', { count: historyEntries.length })}</div>
|
||||||
<div className="git-sidebar-history-legend" aria-label="Commit status legend">
|
<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-item">
|
||||||
<span
|
<span
|
||||||
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--both"
|
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--both"
|
||||||
data-testid="git-history-legend-both"
|
data-testid="git-history-legend-both"
|
||||||
/>
|
/>
|
||||||
Synced
|
{tr('gitSidebar.history.synced')}
|
||||||
</span>
|
</span>
|
||||||
<span className="git-sidebar-history-legend-item">
|
<span className="git-sidebar-history-legend-item">
|
||||||
<span
|
<span
|
||||||
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--local-only"
|
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--local-only"
|
||||||
data-testid="git-history-legend-local-only"
|
data-testid="git-history-legend-local-only"
|
||||||
/>
|
/>
|
||||||
Local only
|
{tr('gitSidebar.history.localOnly')}
|
||||||
</span>
|
</span>
|
||||||
<span className="git-sidebar-history-legend-item">
|
<span className="git-sidebar-history-legend-item">
|
||||||
<span
|
<span
|
||||||
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--remote-only"
|
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--remote-only"
|
||||||
data-testid="git-history-legend-remote-only"
|
data-testid="git-history-legend-remote-only"
|
||||||
/>
|
/>
|
||||||
Remote only
|
{tr('gitSidebar.history.remoteOnly')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{historyLoading ? (
|
{historyLoading ? (
|
||||||
<div className="git-sidebar-empty-state">Loading history...</div>
|
<div className="git-sidebar-empty-state">{tr('gitSidebar.loadingHistory')}</div>
|
||||||
) : historyEntries.length === 0 ? (
|
) : 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) => (
|
{historyEntries.map((entry) => (
|
||||||
<button
|
<button
|
||||||
key={entry.hash}
|
key={entry.hash}
|
||||||
@@ -576,12 +578,12 @@ export const GitSidebar: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 && (
|
{remoteState?.hasUpstream && remoteState.localBranch && remoteState.upstreamBranch && (
|
||||||
<div className="git-sidebar-empty-state">{remoteState.localBranch} → {remoteState.upstreamBranch}</div>
|
<div className="git-sidebar-empty-state">{remoteState.localBranch} → {remoteState.upstreamBranch}</div>
|
||||||
)}
|
)}
|
||||||
{remoteState?.hasUpstream && (
|
{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>}
|
{remoteStateError && <div className="git-sidebar-empty-state git-sidebar-error">{remoteStateError}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -605,20 +607,20 @@ export const GitSidebar: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-sidebar">
|
<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-empty">
|
||||||
<div className="git-sidebar-main">
|
<div className="git-sidebar-main">
|
||||||
<p>This project is not a git repository.</p>
|
<p>{tr('gitSidebar.notRepo')}</p>
|
||||||
<input
|
<input
|
||||||
ref={remoteUrlInputRef}
|
ref={remoteUrlInputRef}
|
||||||
className="git-sidebar-input"
|
className="git-sidebar-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Optional remote repository URL"
|
placeholder={tr('gitSidebar.placeholder.remoteUrl')}
|
||||||
disabled={initializing}
|
disabled={initializing}
|
||||||
/>
|
/>
|
||||||
{initializing && (
|
{initializing && (
|
||||||
<p className="git-sidebar-progress">
|
<p className="git-sidebar-progress">
|
||||||
{initProgress?.message || 'Initializing repository...'}
|
{initProgress?.message || tr('gitSidebar.progress.initializingRepo')}
|
||||||
{typeof initProgress?.progress === 'number' ? ` (${initProgress.progress}%)` : ''}
|
{typeof initProgress?.progress === 'number' ? ` (${initProgress.progress}%)` : ''}
|
||||||
{initProgress?.detail ? ` — ${initProgress.detail}` : ''}
|
{initProgress?.detail ? ` — ${initProgress.detail}` : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -629,7 +631,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
onClick={handleInitialize}
|
onClick={handleInitialize}
|
||||||
disabled={initializing || !projectPath}
|
disabled={initializing || !projectPath}
|
||||||
>
|
>
|
||||||
{initializing ? 'Initializing...' : 'Initialize Git'}
|
{initializing ? tr('gitSidebar.action.initializing') : tr('gitSidebar.action.initializeGit')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{transcriptSection}
|
{transcriptSection}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './InsertModal.css';
|
import './InsertModal.css';
|
||||||
|
|
||||||
interface PostSearchResult {
|
interface PostSearchResult {
|
||||||
@@ -54,6 +55,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
initialText = '',
|
initialText = '',
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('internal');
|
const [activeTab, setActiveTab] = useState<Tab>('internal');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [externalUrl, setExternalUrl] = useState('');
|
const [externalUrl, setExternalUrl] = useState('');
|
||||||
@@ -164,10 +166,10 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
onInsertLink(externalUrl, externalText || undefined);
|
onInsertLink(externalUrl, externalText || undefined);
|
||||||
} else {
|
} else {
|
||||||
// External images don't have a mediaId
|
// External images don't have a mediaId
|
||||||
onInsertImage(externalUrl, externalAlt || 'Image', undefined);
|
onInsertImage(externalUrl, externalAlt || tr('insert.title.image'), undefined);
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
}, [mode, externalUrl, externalText, externalAlt, onInsertLink, onInsertImage, onClose]);
|
}, [mode, externalUrl, externalText, externalAlt, onInsertLink, onInsertImage, onClose, tr]);
|
||||||
|
|
||||||
// Backdrop click handler
|
// Backdrop click handler
|
||||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||||
@@ -184,12 +186,12 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
}
|
}
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
|
|
||||||
const title = mode === 'link' ? 'Insert Link' : 'Insert Image';
|
const title = mode === 'link' ? tr('insert.title.link') : tr('insert.title.image');
|
||||||
const internalLabel = mode === 'link' ? 'Link to Post' : 'Media Library';
|
const internalLabel = mode === 'link' ? tr('insert.tab.linkInternal') : tr('insert.tab.imageInternal');
|
||||||
const externalLabel = mode === 'link' ? 'External URL' : 'External Image';
|
const externalLabel = mode === 'link' ? tr('insert.tab.linkExternal') : tr('insert.tab.imageExternal');
|
||||||
const searchPlaceholder = mode === 'link'
|
const searchPlaceholder = mode === 'link'
|
||||||
? 'Search posts by title or content...'
|
? tr('insert.searchPlaceholder.link')
|
||||||
: 'Search media by name, title, or alt text...';
|
: tr('insert.searchPlaceholder.image');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="insert-modal-backdrop" onClick={handleBackdropClick}>
|
<div className="insert-modal-backdrop" onClick={handleBackdropClick}>
|
||||||
@@ -228,18 +230,18 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
|
|
||||||
<div className="insert-modal-results">
|
<div className="insert-modal-results">
|
||||||
{isSearching && (
|
{isSearching && (
|
||||||
<div className="insert-modal-status">Searching...</div>
|
<div className="insert-modal-status">{tr('insert.status.searching')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isSearching && query.length < 2 && (
|
{!isSearching && query.length < 2 && (
|
||||||
<div className="insert-modal-status">
|
<div className="insert-modal-status">
|
||||||
Type at least 2 characters to search
|
{tr('insert.status.typeMore')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isSearching && query.length >= 2 && results.length === 0 && (
|
{!isSearching && query.length >= 2 && results.length === 0 && (
|
||||||
<div className="insert-modal-status">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -277,12 +279,12 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<div className="insert-modal-external">
|
<div className="insert-modal-external">
|
||||||
<div className="insert-modal-field">
|
<div className="insert-modal-field">
|
||||||
<label className="insert-modal-label">URL</label>
|
<label className="insert-modal-label">{tr('insert.label.url')}</label>
|
||||||
<input
|
<input
|
||||||
ref={externalUrlRef}
|
ref={externalUrlRef}
|
||||||
type="text"
|
type="text"
|
||||||
className="insert-modal-input"
|
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}
|
value={externalUrl}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -291,22 +293,22 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
|
|
||||||
{mode === 'link' ? (
|
{mode === 'link' ? (
|
||||||
<div className="insert-modal-field">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="insert-modal-input"
|
className="insert-modal-input"
|
||||||
placeholder="Click here"
|
placeholder={tr('insert.placeholder.linkText')}
|
||||||
value={externalText}
|
value={externalText}
|
||||||
onChange={(e) => setExternalText(e.target.value)}
|
onChange={(e) => setExternalText(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="insert-modal-field">
|
<div className="insert-modal-field">
|
||||||
<label className="insert-modal-label">Alt Text</label>
|
<label className="insert-modal-label">{tr('insert.label.altText')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="insert-modal-input"
|
className="insert-modal-input"
|
||||||
placeholder="Description of the image"
|
placeholder={tr('insert.placeholder.imageAlt')}
|
||||||
value={externalAlt}
|
value={externalAlt}
|
||||||
onChange={(e) => setExternalAlt(e.target.value)}
|
onChange={(e) => setExternalAlt(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -318,7 +320,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
onClick={handleExternalSubmit}
|
onClick={handleExternalSubmit}
|
||||||
disabled={!externalUrl}
|
disabled={!externalUrl}
|
||||||
>
|
>
|
||||||
Insert {mode === 'link' ? 'Link' : 'Image'}
|
{mode === 'link' ? tr('insert.submit.link') : tr('insert.submit.image')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -327,14 +329,14 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
<div className="insert-modal-footer-content">
|
<div className="insert-modal-footer-content">
|
||||||
<span className="insert-modal-hint">
|
<span className="insert-modal-hint">
|
||||||
{activeTab === 'internal'
|
{activeTab === 'internal'
|
||||||
? 'Use ↑↓ to navigate, Enter to select, Esc to close'
|
? tr('insert.hint.internal')
|
||||||
: 'Enter URL and press Enter or click button, Esc to close'}
|
: tr('insert.hint.external')}
|
||||||
</span>
|
</span>
|
||||||
{activeTab === 'internal' && (
|
{activeTab === 'internal' && (
|
||||||
<span className="insert-modal-format-hint">
|
<span className="insert-modal-format-hint">
|
||||||
{mode === 'link'
|
{mode === 'link'
|
||||||
? 'Canonical: /YYYY/MM/DD/slug'
|
? tr('insert.hint.canonicalPost')
|
||||||
: 'Canonical: /media/YYYY/MM/file.ext'}
|
: tr('insert.hint.canonicalMedia')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './Lightbox.css';
|
import './Lightbox.css';
|
||||||
|
|
||||||
interface LightboxImage {
|
interface LightboxImage {
|
||||||
@@ -20,6 +21,7 @@ export const Lightbox: React.FC<LightboxProps> = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [isZoomed, setIsZoomed] = useState(false);
|
const [isZoomed, setIsZoomed] = useState(false);
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ export const Lightbox: React.FC<LightboxProps> = ({
|
|||||||
<div className="lightbox-overlay" onClick={handleBackdropClick}>
|
<div className="lightbox-overlay" onClick={handleBackdropClick}>
|
||||||
<div className="lightbox-container">
|
<div className="lightbox-container">
|
||||||
{/* Close button */}
|
{/* 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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
@@ -97,12 +99,12 @@ export const Lightbox: React.FC<LightboxProps> = ({
|
|||||||
{/* Navigation arrows */}
|
{/* Navigation arrows */}
|
||||||
{hasMultiple && (
|
{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">
|
<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"/>
|
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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">
|
<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"/>
|
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './MetadataDiffPanel.css';
|
import './MetadataDiffPanel.css';
|
||||||
|
|
||||||
interface TableStats {
|
interface TableStats {
|
||||||
@@ -40,6 +41,7 @@ interface ScanResult {
|
|||||||
type ScanPhase = 'idle' | 'loading-stats' | 'scanning' | 'complete';
|
type ScanPhase = 'idle' | 'loading-stats' | 'scanning' | 'complete';
|
||||||
|
|
||||||
export const MetadataDiffPanel: React.FC = () => {
|
export const MetadataDiffPanel: React.FC = () => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
const [stats, setStats] = useState<TableStats | null>(null);
|
const [stats, setStats] = useState<TableStats | null>(null);
|
||||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||||
const [scanPhase, setScanPhase] = useState<ScanPhase>('idle');
|
const [scanPhase, setScanPhase] = useState<ScanPhase>('idle');
|
||||||
@@ -58,12 +60,12 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load stats:', error);
|
console.error('Failed to load stats:', error);
|
||||||
showToast.error('Failed to load database statistics');
|
showToast.error(tr('metadataDiff.error.loadStats'));
|
||||||
}
|
}
|
||||||
setScanPhase('idle');
|
setScanPhase('idle');
|
||||||
};
|
};
|
||||||
loadStats();
|
loadStats();
|
||||||
}, []);
|
}, [tr]);
|
||||||
|
|
||||||
// Subscribe to task progress
|
// Subscribe to task progress
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,7 +87,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
|
|
||||||
const handleScan = useCallback(async () => {
|
const handleScan = useCallback(async () => {
|
||||||
setScanPhase('scanning');
|
setScanPhase('scanning');
|
||||||
setProgress({ current: 0, total: 100, message: 'Starting scan...' });
|
setProgress({ current: 0, total: 100, message: tr('metadataDiff.progress.starting') });
|
||||||
setScanResult(null);
|
setScanResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -99,10 +101,10 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
setScanPhase('complete');
|
setScanPhase('complete');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Scan failed:', error);
|
console.error('Scan failed:', error);
|
||||||
showToast.error('Failed to scan for differences');
|
showToast.error(tr('metadataDiff.error.scan'));
|
||||||
setScanPhase('idle');
|
setScanPhase('idle');
|
||||||
}
|
}
|
||||||
}, []);
|
}, [tr]);
|
||||||
|
|
||||||
const toggleGroup = (field: string) => {
|
const toggleGroup = (field: string) => {
|
||||||
setExpandedGroups(prev => {
|
setExpandedGroups(prev => {
|
||||||
@@ -123,13 +125,13 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const result = await window.electronAPI?.metadataDiff.syncDbToFile(postIds, group.label);
|
const result = await window.electronAPI?.metadataDiff.syncDbToFile(postIds, group.label);
|
||||||
if (result) {
|
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
|
// Re-scan to update the view
|
||||||
handleScan();
|
handleScan();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync failed:', error);
|
console.error('Sync failed:', error);
|
||||||
showToast.error('Failed to sync to files');
|
showToast.error(tr('metadataDiff.sync.dbToFile.error'));
|
||||||
} finally {
|
} finally {
|
||||||
setSyncingGroups(prev => {
|
setSyncingGroups(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -137,7 +139,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [handleScan]);
|
}, [handleScan, tr]);
|
||||||
|
|
||||||
const handleSyncFileToDb = useCallback(async (group: DiffGroup) => {
|
const handleSyncFileToDb = useCallback(async (group: DiffGroup) => {
|
||||||
const postIds = group.posts.map(p => p.postId);
|
const postIds = group.posts.map(p => p.postId);
|
||||||
@@ -146,13 +148,13 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const result = await window.electronAPI?.metadataDiff.syncFileToDb(postIds, group.field, group.label);
|
const result = await window.electronAPI?.metadataDiff.syncFileToDb(postIds, group.field, group.label);
|
||||||
if (result) {
|
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
|
// Re-scan to update the view
|
||||||
handleScan();
|
handleScan();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync failed:', error);
|
console.error('Sync failed:', error);
|
||||||
showToast.error('Failed to sync to database');
|
showToast.error(tr('metadataDiff.sync.fileToDb.error'));
|
||||||
} finally {
|
} finally {
|
||||||
setSyncingGroups(prev => {
|
setSyncingGroups(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -160,7 +162,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [handleScan]);
|
}, [handleScan, tr]);
|
||||||
|
|
||||||
const formatValue = (value: unknown): string => {
|
const formatValue = (value: unknown): string => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@@ -174,28 +176,28 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="metadata-diff-panel">
|
<div className="metadata-diff-panel">
|
||||||
<h2>Metadata Diff Tool</h2>
|
<h2>{tr('metadataDiff.title')}</h2>
|
||||||
<p style={{ marginBottom: 16, color: 'var(--descriptionForeground)', fontSize: 13 }}>
|
<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>
|
</p>
|
||||||
|
|
||||||
{/* Stats Section */}
|
{/* Stats Section */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="diff-stats">
|
<div className="diff-stats">
|
||||||
<div className="stat-item">
|
<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>
|
<span className="stat-value">{stats.totalPosts}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-item">
|
<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>
|
<span className="stat-value">{stats.publishedPosts}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-item">
|
<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>
|
<span className="stat-value">{stats.draftPosts}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-item">
|
<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>
|
<span className="stat-value">{stats.totalMedia}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,7 +206,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
{/* Progress Section */}
|
{/* Progress Section */}
|
||||||
{scanPhase === 'scanning' && (
|
{scanPhase === 'scanning' && (
|
||||||
<div className="diff-progress">
|
<div className="diff-progress">
|
||||||
<h3>Scanning published posts...</h3>
|
<h3>{tr('metadataDiff.progress.scanningPublished')}</h3>
|
||||||
<div className="progress-bar-container">
|
<div className="progress-bar-container">
|
||||||
<div
|
<div
|
||||||
className="progress-bar"
|
className="progress-bar"
|
||||||
@@ -225,12 +227,12 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
{scanPhase === 'scanning' ? (
|
{scanPhase === 'scanning' ? (
|
||||||
<>
|
<>
|
||||||
<span className="spinner" style={{ width: 14, height: 14 }} />
|
<span className="spinner" style={{ width: 14, height: 14 }} />
|
||||||
Scanning...
|
{tr('metadataDiff.progress.scanning')}
|
||||||
</>
|
</>
|
||||||
) : scanResult ? (
|
) : scanResult ? (
|
||||||
'🔄 Re-scan'
|
`🔄 ${tr('metadataDiff.action.rescan')}`
|
||||||
) : (
|
) : (
|
||||||
'🔍 Scan for Differences'
|
`🔍 ${tr('metadataDiff.action.scan')}`
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,11 +242,10 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
<div className="diff-results">
|
<div className="diff-results">
|
||||||
<div className={`diff-summary ${scanResult.postsWithDifferences > 0 ? 'has-differences' : 'no-differences'}`}>
|
<div className={`diff-summary ${scanResult.postsWithDifferences > 0 ? 'has-differences' : 'no-differences'}`}>
|
||||||
{scanResult.postsWithDifferences === 0 ? (
|
{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
|
{tr('metadataDiff.summary.withDiffs', { count: scanResult.postsWithDifferences, total: scanResult.totalScanned })}
|
||||||
out of {scanResult.totalScanned} published posts.
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -260,16 +261,16 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
<span className={`chevron ${expandedGroups.has(group.field) ? 'expanded' : ''}`}>
|
<span className={`chevron ${expandedGroups.has(group.field) ? 'expanded' : ''}`}>
|
||||||
▶
|
▶
|
||||||
</span>
|
</span>
|
||||||
{group.label} Differences
|
{tr('metadataDiff.group.differences', { label: group.label })}
|
||||||
</div>
|
</div>
|
||||||
<div className="diff-group-count">
|
<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()}>
|
<div className="diff-group-actions" onClick={e => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
className="db-to-file"
|
className="db-to-file"
|
||||||
onClick={() => handleSyncDbToFile(group)}
|
onClick={() => handleSyncDbToFile(group)}
|
||||||
disabled={syncingGroups.has(group.field)}
|
disabled={syncingGroups.has(group.field)}
|
||||||
title="Update files with database values"
|
title={tr('metadataDiff.sync.dbToFile.title')}
|
||||||
>
|
>
|
||||||
DB → File
|
DB → File
|
||||||
</button>
|
</button>
|
||||||
@@ -277,7 +278,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
className="file-to-db"
|
className="file-to-db"
|
||||||
onClick={() => handleSyncFileToDb(group)}
|
onClick={() => handleSyncFileToDb(group)}
|
||||||
disabled={syncingGroups.has(group.field)}
|
disabled={syncingGroups.has(group.field)}
|
||||||
title="Update database with file values"
|
title={tr('metadataDiff.sync.fileToDb.title')}
|
||||||
>
|
>
|
||||||
File → DB
|
File → DB
|
||||||
</button>
|
</button>
|
||||||
@@ -291,13 +292,13 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
{post.title || post.slug}
|
{post.title || post.slug}
|
||||||
</div>
|
</div>
|
||||||
<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)}>
|
<div className="diff-value db-value" title={formatValue(post.dbValue)}>
|
||||||
{formatValue(post.dbValue)}
|
{formatValue(post.dbValue)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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)}>
|
<div className="diff-value file-value" title={formatValue(post.fileValue)}>
|
||||||
{formatValue(post.fileValue)}
|
{formatValue(post.fileValue)}
|
||||||
</div>
|
</div>
|
||||||
@@ -314,7 +315,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
{scanPhase === 'idle' && !scanResult && (
|
{scanPhase === 'idle' && !scanResult && (
|
||||||
<div className="diff-empty">
|
<div className="diff-empty">
|
||||||
<div className="icon">📊</div>
|
<div className="icon">📊</div>
|
||||||
<div>Click "Scan for Differences" to compare database metadata with file metadata.</div>
|
<div>{tr('metadataDiff.empty')}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import '../../macros';
|
|||||||
import './MilkdownEditor.css';
|
import './MilkdownEditor.css';
|
||||||
import { InsertModal } from '../InsertModal';
|
import { InsertModal } from '../InsertModal';
|
||||||
import { normalizeMilkdownMarkdown } from '../../utils/markdownEscape';
|
import { normalizeMilkdownMarkdown } from '../../utils/markdownEscape';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
|
|
||||||
// Remark plugin to force tight lists (no blank lines between list items)
|
// Remark plugin to force tight lists (no blank lines between list items)
|
||||||
const remarkTightListsPlugin: Plugin<[Record<string, unknown>], Root> = () => {
|
const remarkTightListsPlugin: Plugin<[Record<string, unknown>], Root> = () => {
|
||||||
@@ -89,6 +90,7 @@ interface EditorToolbarProps {
|
|||||||
|
|
||||||
// Toolbar component that uses the editor instance
|
// Toolbar component that uses the editor instance
|
||||||
const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
|
const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
const [loading, getEditor] = useInstance();
|
const [loading, getEditor] = useInstance();
|
||||||
const [insertMode, setInsertMode] = useState<InsertModalMode>(null);
|
const [insertMode, setInsertMode] = useState<InsertModalMode>(null);
|
||||||
const [selectedText, setSelectedText] = useState('');
|
const [selectedText, setSelectedText] = useState('');
|
||||||
@@ -218,21 +220,21 @@ const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
|
|||||||
<>
|
<>
|
||||||
<div className="milkdown-toolbar">
|
<div className="milkdown-toolbar">
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<button onClick={() => insertHeading(1)} title="Heading 1">H1</button>
|
<button onClick={() => insertHeading(1)} title={tr('milkdown.heading1')}>H1</button>
|
||||||
<button onClick={() => insertHeading(2)} title="Heading 2">H2</button>
|
<button onClick={() => insertHeading(2)} title={tr('milkdown.heading2')}>H2</button>
|
||||||
<button onClick={() => insertHeading(3)} title="Heading 3">H3</button>
|
<button onClick={() => insertHeading(3)} title={tr('milkdown.heading3')}>H3</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-divider" />
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
<div className="toolbar-group">
|
<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>
|
<strong>B</strong>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => runCommand(toggleEmphasisCommand.key)} title="Italic (Ctrl+I)">
|
<button onClick={() => runCommand(toggleEmphasisCommand.key)} title={tr('milkdown.italic')}>
|
||||||
<em>I</em>
|
<em>I</em>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => runCommand(toggleStrikethroughCommand.key)} title="Strikethrough">
|
<button onClick={() => runCommand(toggleStrikethroughCommand.key)} title={tr('milkdown.strikethrough')}>
|
||||||
<s>S</s>
|
<s>S</s>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,25 +242,25 @@ const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
|
|||||||
<div className="toolbar-divider" />
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<button onClick={() => runCommand(wrapInBulletListCommand.key)} title="Bullet List">•</button>
|
<button onClick={() => runCommand(wrapInBulletListCommand.key)} title={tr('milkdown.bulletList')}>•</button>
|
||||||
<button onClick={() => runCommand(wrapInOrderedListCommand.key)} title="Numbered List">1.</button>
|
<button onClick={() => runCommand(wrapInOrderedListCommand.key)} title={tr('milkdown.numberedList')}>1.</button>
|
||||||
<button onClick={() => runCommand(wrapInBlockquoteCommand.key)} title="Quote">❝</button>
|
<button onClick={() => runCommand(wrapInBlockquoteCommand.key)} title={tr('milkdown.quote')}>❝</button>
|
||||||
<button onClick={() => runCommand(toggleInlineCodeCommand.key)} title="Code">{'{}'}</button>
|
<button onClick={() => runCommand(toggleInlineCodeCommand.key)} title={tr('milkdown.code')}>{'{}'}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-divider" />
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<button onClick={openLinkModal} title="Insert Link (Ctrl+K)">🔗</button>
|
<button onClick={openLinkModal} title={tr('milkdown.insertLink')}>🔗</button>
|
||||||
<button onClick={openImageModal} title="Insert Image">🖼</button>
|
<button onClick={openImageModal} title={tr('milkdown.insertImage')}>🖼</button>
|
||||||
<button onClick={() => runCommand(insertHrCommand.key)} title="Horizontal Rule">―</button>
|
<button onClick={() => runCommand(insertHrCommand.key)} title={tr('milkdown.horizontalRule')}>―</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-divider" />
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<button onClick={() => runCommand(undoCommand.key)} title="Undo (Ctrl+Z)">↶</button>
|
<button onClick={() => runCommand(undoCommand.key)} title={tr('milkdown.undo')}>↶</button>
|
||||||
<button onClick={() => runCommand(redoCommand.key)} title="Redo (Ctrl+Y)">↷</button>
|
<button onClick={() => runCommand(redoCommand.key)} title={tr('milkdown.redo')}>↷</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -288,8 +290,10 @@ export const MilkdownEditor: React.FC<MilkdownEditorProps> = (props) => {
|
|||||||
const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
|
const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
|
||||||
content,
|
content,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Start writing your content...',
|
placeholder,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
|
const resolvedPlaceholder = placeholder || tr('editor.placeholder');
|
||||||
const [loading, getEditor] = useInstance();
|
const [loading, getEditor] = useInstance();
|
||||||
const lastExternalContent = useRef(content);
|
const lastExternalContent = useRef(content);
|
||||||
const isInternalChange = useRef(false);
|
const isInternalChange = useRef(false);
|
||||||
@@ -375,7 +379,7 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
|
|||||||
onInputCapture={markUserInteraction}
|
onInputCapture={markUserInteraction}
|
||||||
>
|
>
|
||||||
<EditorToolbar onUserInteraction={markUserInteraction} />
|
<EditorToolbar onUserInteraction={markUserInteraction} />
|
||||||
<div className="milkdown-content" data-placeholder={placeholder}>
|
<div className="milkdown-content" data-placeholder={resolvedPlaceholder}>
|
||||||
<Milkdown />
|
<Milkdown />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './PostLinks.css';
|
import './PostLinks.css';
|
||||||
|
|
||||||
interface PostLinkInfo {
|
interface PostLinkInfo {
|
||||||
@@ -14,6 +15,7 @@ interface PostLinksProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updatedAt }) => {
|
export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updatedAt }) => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
const [linksTo, setLinksTo] = useState<PostLinkInfo[]>([]);
|
const [linksTo, setLinksTo] = useState<PostLinkInfo[]>([]);
|
||||||
const [linkedBy, setLinkedBy] = useState<PostLinkInfo[]>([]);
|
const [linkedBy, setLinkedBy] = useState<PostLinkInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -44,7 +46,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="post-links">
|
<div className="post-links">
|
||||||
<div className="post-links-loading">Loading links...</div>
|
<div className="post-links-loading">{tr('postLinks.loading')}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -60,7 +62,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
|
|||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
>
|
>
|
||||||
<span className="post-links-icon">🔗</span>
|
<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>
|
<span className="post-links-chevron">{expanded ? '▼' : '▶'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
|
|||||||
<div className="post-links-section">
|
<div className="post-links-section">
|
||||||
<h4 className="post-links-heading">
|
<h4 className="post-links-heading">
|
||||||
<span className="post-links-arrow">→</span>
|
<span className="post-links-arrow">→</span>
|
||||||
Links to ({linksTo.length})
|
{tr('postLinks.linksTo', { count: linksTo.length })}
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="post-links-list">
|
<ul className="post-links-list">
|
||||||
{linksTo.map(link => (
|
{linksTo.map(link => (
|
||||||
@@ -78,7 +80,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
|
|||||||
<button
|
<button
|
||||||
className="post-link-item"
|
className="post-link-item"
|
||||||
onClick={() => onPostClick?.(link.id)}
|
onClick={() => onPostClick?.(link.id)}
|
||||||
title={`Open: ${link.title}`}
|
title={tr('postLinks.openTitle', { title: link.title })}
|
||||||
>
|
>
|
||||||
{link.title}
|
{link.title}
|
||||||
</button>
|
</button>
|
||||||
@@ -92,7 +94,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
|
|||||||
<div className="post-links-section">
|
<div className="post-links-section">
|
||||||
<h4 className="post-links-heading">
|
<h4 className="post-links-heading">
|
||||||
<span className="post-links-arrow">←</span>
|
<span className="post-links-arrow">←</span>
|
||||||
Linked by ({linkedBy.length})
|
{tr('postLinks.linkedBy', { count: linkedBy.length })}
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="post-links-list">
|
<ul className="post-links-list">
|
||||||
{linkedBy.map(link => (
|
{linkedBy.map(link => (
|
||||||
@@ -100,7 +102,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
|
|||||||
<button
|
<button
|
||||||
className="post-link-item"
|
className="post-link-item"
|
||||||
onClick={() => onPostClick?.(link.id)}
|
onClick={() => onPostClick?.(link.id)}
|
||||||
title={`Open: ${link.title}`}
|
title={tr('postLinks.openTitle', { title: link.title })}
|
||||||
>
|
>
|
||||||
{link.title}
|
{link.title}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './SettingsView.css';
|
import './SettingsView.css';
|
||||||
|
|
||||||
// Export category IDs for sidebar navigation
|
// Export category IDs for sidebar navigation
|
||||||
@@ -102,6 +103,7 @@ const SettingSection: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsView: React.FC = () => {
|
export const SettingsView: React.FC = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
const {
|
const {
|
||||||
preferredEditorMode,
|
preferredEditorMode,
|
||||||
setPreferredEditorMode,
|
setPreferredEditorMode,
|
||||||
@@ -255,10 +257,10 @@ export const SettingsView: React.FC = () => {
|
|||||||
const handleSavePublishing = async () => {
|
const handleSavePublishing = async () => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
|
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
|
||||||
showToast.success('Publishing credentials saved');
|
showToast.success(t('settings.toast.publishingSaved'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save publishing credentials:', 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);
|
setCredentials(newCreds);
|
||||||
localStorage.setItem('bds-credentials', JSON.stringify(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
|
// Save project settings
|
||||||
@@ -306,10 +308,10 @@ export const SettingsView: React.FC = () => {
|
|||||||
categorySettings,
|
categorySettings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
showToast.success('Project settings saved');
|
showToast.success(t('settings.toast.projectSaved'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save project settings:', 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 = () => (
|
const renderProjectSettings = () => (
|
||||||
<SettingSection
|
<SettingSection
|
||||||
id="settings-section-project"
|
id="settings-section-project"
|
||||||
title="Project"
|
title={t('settings.project.title')}
|
||||||
description="General settings for the active blog project."
|
description="General settings for the active blog project."
|
||||||
hidden={!sectionHasMatches(projectKeywords)}
|
hidden={!sectionHasMatches(projectKeywords)}
|
||||||
>
|
>
|
||||||
@@ -380,12 +382,12 @@ export const SettingsView: React.FC = () => {
|
|||||||
value={projectDataPath}
|
value={projectDataPath}
|
||||||
onChange={(e) => setProjectDataPath(e.target.value)}
|
onChange={(e) => setProjectDataPath(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button className="secondary" onClick={handleBrowseDataPath} title="Browse...">
|
<button className="secondary" onClick={handleBrowseDataPath} title={t('settings.project.browse')}>
|
||||||
Browse
|
{t('settings.project.browse')}
|
||||||
</button>
|
</button>
|
||||||
{projectDataPath && (
|
{projectDataPath && (
|
||||||
<button className="secondary" onClick={handleResetDataPath} title="Reset to default">
|
<button className="secondary" onClick={handleResetDataPath} title={t('settings.project.resetDefault')}>
|
||||||
Reset
|
{t('settings.project.reset')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -415,26 +417,26 @@ export const SettingsView: React.FC = () => {
|
|||||||
value={projectMainLanguage}
|
value={projectMainLanguage}
|
||||||
onChange={(e) => setProjectMainLanguage(e.target.value)}
|
onChange={(e) => setProjectMainLanguage(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="en">English</option>
|
<option value="en">{t('settings.language.english')}</option>
|
||||||
<option value="de">German (Deutsch)</option>
|
<option value="de">{t('settings.language.german')}</option>
|
||||||
<option value="es">Spanish (Español)</option>
|
<option value="es">{t('settings.language.spanish')}</option>
|
||||||
<option value="fr">French (Français)</option>
|
<option value="fr">{t('settings.language.french')}</option>
|
||||||
<option value="it">Italian (Italiano)</option>
|
<option value="it">{t('settings.language.italian')}</option>
|
||||||
<option value="pt">Portuguese (Português)</option>
|
<option value="pt">{t('settings.language.portuguese')}</option>
|
||||||
<option value="nl">Dutch (Nederlands)</option>
|
<option value="nl">{t('settings.language.dutch')}</option>
|
||||||
<option value="pl">Polish (Polski)</option>
|
<option value="pl">{t('settings.language.polish')}</option>
|
||||||
<option value="ru">Russian (Русский)</option>
|
<option value="ru">{t('settings.language.russian')}</option>
|
||||||
<option value="ja">Japanese (日本語)</option>
|
<option value="ja">{t('settings.language.japanese')}</option>
|
||||||
<option value="zh">Chinese (中文)</option>
|
<option value="zh">{t('settings.language.chinese')}</option>
|
||||||
<option value="ko">Korean (한국어)</option>
|
<option value="ko">{t('settings.language.korean')}</option>
|
||||||
<option value="ar">Arabic (العربية)</option>
|
<option value="ar">{t('settings.language.arabic')}</option>
|
||||||
<option value="hi">Hindi (हिन्दी)</option>
|
<option value="hi">{t('settings.language.hindi')}</option>
|
||||||
<option value="tr">Turkish (Türkçe)</option>
|
<option value="tr">{t('settings.language.turkish')}</option>
|
||||||
<option value="sv">Swedish (Svenska)</option>
|
<option value="sv">{t('settings.language.swedish')}</option>
|
||||||
<option value="da">Danish (Dansk)</option>
|
<option value="da">{t('settings.language.danish')}</option>
|
||||||
<option value="no">Norwegian (Norsk)</option>
|
<option value="no">{t('settings.language.norwegian')}</option>
|
||||||
<option value="fi">Finnish (Suomi)</option>
|
<option value="fi">{t('settings.language.finnish')}</option>
|
||||||
<option value="cs">Czech (Čeština)</option>
|
<option value="cs">{t('settings.language.czech')}</option>
|
||||||
</select>
|
</select>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
@@ -485,7 +487,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
const renderEditorSettings = () => (
|
const renderEditorSettings = () => (
|
||||||
<SettingSection
|
<SettingSection
|
||||||
id="settings-section-editor"
|
id="settings-section-editor"
|
||||||
title="Editor"
|
title={t('settings.editor.title')}
|
||||||
description="Configure the blog post editor behavior and appearance."
|
description="Configure the blog post editor behavior and appearance."
|
||||||
hidden={!sectionHasMatches(editorKeywords)}
|
hidden={!sectionHasMatches(editorKeywords)}
|
||||||
>
|
>
|
||||||
@@ -499,9 +501,9 @@ export const SettingsView: React.FC = () => {
|
|||||||
value={preferredEditorMode}
|
value={preferredEditorMode}
|
||||||
onChange={(e) => setPreferredEditorMode(e.target.value as 'wysiwyg' | 'markdown' | 'preview')}
|
onChange={(e) => setPreferredEditorMode(e.target.value as 'wysiwyg' | 'markdown' | 'preview')}
|
||||||
>
|
>
|
||||||
<option value="wysiwyg">WYSIWYG (Visual Editor)</option>
|
<option value="wysiwyg">{t('settings.editor.mode.wysiwyg')}</option>
|
||||||
<option value="markdown">Markdown (Source)</option>
|
<option value="markdown">{t('settings.editor.mode.markdown')}</option>
|
||||||
<option value="preview">Preview (Read-only)</option>
|
<option value="preview">{t('settings.editor.mode.preview')}</option>
|
||||||
</select>
|
</select>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
@@ -521,8 +523,8 @@ export const SettingsView: React.FC = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="inline">Inline</option>
|
<option value="inline">{t('settings.editor.diff.inline')}</option>
|
||||||
<option value="side-by-side">Side by Side</option>
|
<option value="side-by-side">{t('settings.editor.diff.sideBySide')}</option>
|
||||||
</select>
|
</select>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
@@ -582,23 +584,23 @@ export const SettingsView: React.FC = () => {
|
|||||||
setCategorySettings(nextSettings);
|
setCategorySettings(nextSettings);
|
||||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
||||||
setNewCategoryInput('');
|
setNewCategoryInput('');
|
||||||
showToast.success(`Category "${trimmed}" added`);
|
showToast.success(t('settings.toast.categoryAdded', { category: trimmed }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add category:', 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)) {
|
} else if (postCategories.includes(trimmed)) {
|
||||||
showToast.error('Category already exists');
|
showToast.error(t('settings.toast.categoryExists'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveCategory = async (categoryToRemove: string) => {
|
const handleRemoveCategory = async (categoryToRemove: string) => {
|
||||||
if (PROTECTED_CATEGORIES.includes(categoryToRemove)) {
|
if (PROTECTED_CATEGORIES.includes(categoryToRemove)) {
|
||||||
showToast.error(`Cannot delete standard category "${categoryToRemove}"`);
|
showToast.error(t('settings.toast.categoryProtected', { category: categoryToRemove }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (postCategories.length <= 1) {
|
if (postCategories.length <= 1) {
|
||||||
showToast.error('Must have at least one category');
|
showToast.error(t('settings.toast.categoryAtLeastOne'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -610,10 +612,10 @@ export const SettingsView: React.FC = () => {
|
|||||||
delete nextSettings[categoryToRemove];
|
delete nextSettings[categoryToRemove];
|
||||||
setCategorySettings(nextSettings);
|
setCategorySettings(nextSettings);
|
||||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
||||||
showToast.success(`Category "${categoryToRemove}" removed`);
|
showToast.success(t('settings.toast.categoryRemoved', { category: categoryToRemove }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove category:', 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 };
|
const defaults = { ...DEFAULT_CATEGORY_SETTINGS };
|
||||||
setCategorySettings(defaults);
|
setCategorySettings(defaults);
|
||||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: defaults });
|
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: defaults });
|
||||||
showToast.success('Categories reset to defaults');
|
showToast.success(t('settings.toast.categoriesReset'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to reset categories:', 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 });
|
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update category settings:', error);
|
console.error('Failed to update category settings:', error);
|
||||||
showToast.error('Failed to update category settings');
|
showToast.error(t('settings.toast.categorySettingsUpdateFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderContentSettings = () => (
|
const renderContentSettings = () => (
|
||||||
<SettingSection
|
<SettingSection
|
||||||
id="settings-section-content"
|
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."
|
description="Manage the available categories for blog posts. Each post can have one category that determines its display template."
|
||||||
hidden={!sectionHasMatches(contentKeywords)}
|
hidden={!sectionHasMatches(contentKeywords)}
|
||||||
>
|
>
|
||||||
@@ -689,7 +691,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
checked={setting.renderInLists}
|
checked={setting.renderInLists}
|
||||||
onChange={(event) => handleCategorySettingToggle(cat, 'renderInLists', event.target.checked)}
|
onChange={(event) => handleCategorySettingToggle(cat, 'renderInLists', event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>Render in lists</span>
|
<span>{t('settings.content.renderInLists')}</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="category-setting-toggle" htmlFor={`category-${cat}-show-title`}>
|
<label className="category-setting-toggle" htmlFor={`category-${cat}-show-title`}>
|
||||||
<input
|
<input
|
||||||
@@ -699,7 +701,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
checked={setting.showTitle}
|
checked={setting.showTitle}
|
||||||
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
|
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>Show titles</span>
|
<span>{t('settings.content.showTitles')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{!isProtected && (
|
{!isProtected && (
|
||||||
@@ -748,13 +750,13 @@ export const SettingsView: React.FC = () => {
|
|||||||
const result = await window.electronAPI?.chat.setSystemPrompt(aiSystemPrompt);
|
const result = await window.electronAPI?.chat.setSystemPrompt(aiSystemPrompt);
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
setAiSystemPromptModified(false);
|
setAiSystemPromptModified(false);
|
||||||
showToast.success('System prompt saved');
|
showToast.success(t('settings.toast.systemPromptSaved'));
|
||||||
} else {
|
} else {
|
||||||
showToast.error('Failed to save system prompt');
|
showToast.error(t('settings.toast.systemPromptSaveFailed'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save system prompt:', 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) {
|
if (result?.success) {
|
||||||
setAiSystemPrompt(result.prompt || '');
|
setAiSystemPrompt(result.prompt || '');
|
||||||
setAiSystemPromptModified(false);
|
setAiSystemPromptModified(false);
|
||||||
showToast.success('System prompt reset to default');
|
showToast.success(t('settings.toast.systemPromptReset'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to reset system prompt:', 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);
|
setAiHasApiKey(true);
|
||||||
setAiApiKeyMasked('•'.repeat(Math.max(0, newApiKey.length - 4)) + newApiKey.slice(-4));
|
setAiApiKeyMasked('•'.repeat(Math.max(0, newApiKey.length - 4)) + newApiKey.slice(-4));
|
||||||
setNewApiKey('');
|
setNewApiKey('');
|
||||||
showToast.success('API key saved and validated');
|
showToast.success(t('settings.toast.apiKeySaved'));
|
||||||
} else {
|
} else {
|
||||||
showToast.error('Invalid API key');
|
showToast.error(t('settings.toast.apiKeyInvalid'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save API key:', 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);
|
const result = await window.electronAPI?.chat.setDefaultModel(modelId);
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
setSelectedModel(modelId);
|
setSelectedModel(modelId);
|
||||||
showToast.success('Default model updated');
|
showToast.success(t('settings.toast.defaultModelUpdated'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to set model:', error);
|
console.error('Failed to set model:', error);
|
||||||
showToast.error('Failed to set default model');
|
showToast.error(t('settings.toast.defaultModelUpdateFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAISettings = () => (
|
const renderAISettings = () => (
|
||||||
<SettingSection
|
<SettingSection
|
||||||
id="settings-section-ai"
|
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."
|
description="Configure the AI chat assistant that helps you manage your blog content."
|
||||||
hidden={!sectionHasMatches(aiKeywords)}
|
hidden={!sectionHasMatches(aiKeywords)}
|
||||||
>
|
>
|
||||||
@@ -865,7 +867,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
onChange={(e) => handleModelChange(e.target.value)}
|
onChange={(e) => handleModelChange(e.target.value)}
|
||||||
disabled={!aiHasApiKey}
|
disabled={!aiHasApiKey}
|
||||||
>
|
>
|
||||||
{availableModels.length === 0 && <option value="">No models available</option>}
|
{availableModels.length === 0 && <option value="">{t('settings.ai.noModels')}</option>}
|
||||||
{availableModels.map(model => (
|
{availableModels.map(model => (
|
||||||
<option key={model.id} value={model.id}>{model.name}</option>
|
<option key={model.id} value={model.id}>{model.name}</option>
|
||||||
))}
|
))}
|
||||||
@@ -908,7 +910,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<SettingSection
|
<SettingSection
|
||||||
id="settings-section-publishing"
|
id="settings-section-publishing"
|
||||||
title="FTP Publishing"
|
title={t('settings.publishing.ftpTitle')}
|
||||||
description="Configure FTP credentials for publishing your blog to a web server."
|
description="Configure FTP credentials for publishing your blog to a web server."
|
||||||
hidden={!sectionHasMatches(publishingKeywords)}
|
hidden={!sectionHasMatches(publishingKeywords)}
|
||||||
>
|
>
|
||||||
@@ -964,13 +966,13 @@ export const SettingsView: React.FC = () => {
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
<div className="setting-actions">
|
<div className="setting-actions">
|
||||||
<button className="primary" onClick={handleSavePublishing}>Save</button>
|
<button className="primary" onClick={handleSavePublishing}>{t('common.save')}</button>
|
||||||
<button className="secondary danger" onClick={() => handleClearCredentials('ftp')}>Clear</button>
|
<button className="secondary danger" onClick={() => handleClearCredentials('ftp')}>{t('common.clear')}</button>
|
||||||
</div>
|
</div>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
<SettingSection
|
<SettingSection
|
||||||
title="SSH Publishing"
|
title={t('settings.publishing.sshTitle')}
|
||||||
description="Configure SSH credentials for secure deployment to your server."
|
description="Configure SSH credentials for secure deployment to your server."
|
||||||
hidden={!sectionHasMatches(publishingKeywords)}
|
hidden={!sectionHasMatches(publishingKeywords)}
|
||||||
>
|
>
|
||||||
@@ -1017,8 +1019,8 @@ export const SettingsView: React.FC = () => {
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
<div className="setting-actions">
|
<div className="setting-actions">
|
||||||
<button className="primary" onClick={handleSavePublishing}>Save</button>
|
<button className="primary" onClick={handleSavePublishing}>{t('common.save')}</button>
|
||||||
<button className="secondary danger" onClick={() => handleClearCredentials('ssh')}>Clear</button>
|
<button className="secondary danger" onClick={() => handleClearCredentials('ssh')}>{t('common.clear')}</button>
|
||||||
</div>
|
</div>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
</>
|
</>
|
||||||
@@ -1028,7 +1030,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<SettingSection
|
<SettingSection
|
||||||
id="settings-section-data"
|
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."
|
description="Rebuild the local database index from source files. Useful if post or media files were edited externally."
|
||||||
hidden={!sectionHasMatches(dataKeywords)}
|
hidden={!sectionHasMatches(dataKeywords)}
|
||||||
>
|
>
|
||||||
@@ -1040,7 +1042,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
showToast.loading('Rebuilding posts database...');
|
showToast.loading(t('settings.toast.rebuildPostsLoading'));
|
||||||
try {
|
try {
|
||||||
await window.electronAPI?.posts.rebuildFromFiles();
|
await window.electronAPI?.posts.rebuildFromFiles();
|
||||||
const postsResult = await window.electronAPI?.posts.getAll({ limit: 500, offset: 0 });
|
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);
|
useAppStore.getState().setPosts(postsResult.items, postsResult.hasMore, postsResult.total);
|
||||||
}
|
}
|
||||||
showToast.dismiss();
|
showToast.dismiss();
|
||||||
showToast.success('Posts database rebuilt');
|
showToast.success(t('settings.toast.rebuildPostsSuccess'));
|
||||||
} catch {
|
} catch {
|
||||||
showToast.dismiss();
|
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
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
showToast.loading('Rebuilding media database...');
|
showToast.loading(t('settings.toast.rebuildMediaLoading'));
|
||||||
try {
|
try {
|
||||||
await window.electronAPI?.media.rebuildFromFiles();
|
await window.electronAPI?.media.rebuildFromFiles();
|
||||||
const media = await window.electronAPI?.media.getAll();
|
const media = await window.electronAPI?.media.getAll();
|
||||||
@@ -1075,10 +1077,10 @@ export const SettingsView: React.FC = () => {
|
|||||||
useAppStore.getState().setMedia(media as any[]);
|
useAppStore.getState().setMedia(media as any[]);
|
||||||
}
|
}
|
||||||
showToast.dismiss();
|
showToast.dismiss();
|
||||||
showToast.success('Media database rebuilt');
|
showToast.success(t('settings.toast.rebuildMediaSuccess'));
|
||||||
} catch {
|
} catch {
|
||||||
showToast.dismiss();
|
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
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
showToast.loading('Rebuilding post links...');
|
showToast.loading(t('settings.toast.rebuildLinksLoading'));
|
||||||
try {
|
try {
|
||||||
await window.electronAPI?.posts.rebuildLinks();
|
await window.electronAPI?.posts.rebuildLinks();
|
||||||
showToast.dismiss();
|
showToast.dismiss();
|
||||||
showToast.success('Post links rebuilt');
|
showToast.success(t('settings.toast.rebuildLinksSuccess'));
|
||||||
} catch {
|
} catch {
|
||||||
showToast.dismiss();
|
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
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
showToast.loading('Generating thumbnails...');
|
showToast.loading(t('settings.toast.thumbnailsLoading'));
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI?.media.regenerateMissingThumbnails();
|
const result = await window.electronAPI?.media.regenerateMissingThumbnails();
|
||||||
showToast.dismiss();
|
showToast.dismiss();
|
||||||
if (result && result.generated > 0) {
|
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) {
|
} else if (result && result.processed === 0) {
|
||||||
showToast.success('All thumbnails already exist');
|
showToast.success(t('settings.toast.thumbnailsAlreadyExist'));
|
||||||
} else {
|
} else {
|
||||||
showToast.success('Thumbnail generation complete');
|
showToast.success(t('settings.toast.thumbnailsComplete'));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
showToast.dismiss();
|
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>
|
||||||
|
|
||||||
<SettingSection
|
<SettingSection
|
||||||
title="File System"
|
title={t('settings.data.fileSystemTitle')}
|
||||||
description="Access project data files and directories."
|
description="Access project data files and directories."
|
||||||
hidden={!sectionHasMatches(dataKeywords)}
|
hidden={!sectionHasMatches(dataKeywords)}
|
||||||
>
|
>
|
||||||
@@ -1178,12 +1180,12 @@ export const SettingsView: React.FC = () => {
|
|||||||
<div className="settings-view">
|
<div className="settings-view">
|
||||||
{/* Header with search */}
|
{/* Header with search */}
|
||||||
<div className="settings-header">
|
<div className="settings-header">
|
||||||
<h2>Settings</h2>
|
<h2>{t('common.settings')}</h2>
|
||||||
<div className="settings-search">
|
<div className="settings-search">
|
||||||
<span className="settings-search-icon"><SearchIcon /></span>
|
<span className="settings-search-icon"><SearchIcon /></span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search settings..."
|
placeholder={t('settings.search.placeholder')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -1211,8 +1213,8 @@ export const SettingsView: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="settings-no-results">
|
<div className="settings-no-results">
|
||||||
<p>No settings found matching "{searchQuery}"</p>
|
<p>{t('settings.search.noResults', { query: searchQuery })}</p>
|
||||||
<button onClick={() => setSearchQuery('')}>Clear search</button>
|
<button onClick={() => setSearchQuery('')}>{t('settings.search.clear')}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import { useAppStore, Tab } from '../../store';
|
import { useAppStore, Tab } from '../../store';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './TabBar.css';
|
import './TabBar.css';
|
||||||
|
|
||||||
const MAX_CHAT_TITLE_LENGTH = 18;
|
const MAX_CHAT_TITLE_LENGTH = 18;
|
||||||
@@ -22,7 +23,8 @@ const getTabTitle = (
|
|||||||
media: { id: string; originalName: string }[],
|
media: { id: string; originalName: string }[],
|
||||||
chatTitles: Map<string, string>,
|
chatTitles: Map<string, string>,
|
||||||
importDefTitles: 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 => {
|
): string => {
|
||||||
if (tab.type === 'git-diff') {
|
if (tab.type === 'git-diff') {
|
||||||
const filePath = getGitDiffResource(tab.id);
|
const filePath = getGitDiffResource(tab.id);
|
||||||
@@ -32,57 +34,57 @@ const getTabTitle = (
|
|||||||
if (commitTitle) {
|
if (commitTitle) {
|
||||||
return commitTitle;
|
return commitTitle;
|
||||||
}
|
}
|
||||||
return `Commit ${commitHash.slice(0, 7)}`;
|
return tr('tabBar.commitTitle', { hash: commitHash.slice(0, 7) });
|
||||||
}
|
}
|
||||||
const filename = filePath.split('/').pop();
|
const filename = filePath.split('/').pop();
|
||||||
return filename || filePath;
|
return filename || filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab.type === 'settings') {
|
if (tab.type === 'settings') {
|
||||||
return 'Settings';
|
return tr('common.settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab.type === 'style') {
|
if (tab.type === 'style') {
|
||||||
return 'Style';
|
return tr('tabBar.style');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab.type === 'tags') {
|
if (tab.type === 'tags') {
|
||||||
return 'Tags';
|
return tr('activity.tags');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab.type === 'post') {
|
if (tab.type === 'post') {
|
||||||
return postTitles.get(tab.id) || 'Loading...';
|
return postTitles.get(tab.id) || tr('tabBar.loading');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab.type === 'media') {
|
if (tab.type === 'media') {
|
||||||
const mediaItem = media.find(m => m.id === tab.id);
|
const mediaItem = media.find(m => m.id === tab.id);
|
||||||
return mediaItem?.originalName || 'Media';
|
return mediaItem?.originalName || tr('activity.media');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab.type === 'chat') {
|
if (tab.type === 'chat') {
|
||||||
const title = chatTitles.get(tab.id);
|
const title = chatTitles.get(tab.id);
|
||||||
if (title && title !== 'New Chat') {
|
if (title && title !== tr('chat.newChat')) {
|
||||||
// Truncate long titles for display
|
// Truncate long titles for display
|
||||||
return title.length > MAX_CHAT_TITLE_LENGTH
|
return title.length > MAX_CHAT_TITLE_LENGTH
|
||||||
? title.substring(0, MAX_CHAT_TITLE_LENGTH) + '…'
|
? title.substring(0, MAX_CHAT_TITLE_LENGTH) + '…'
|
||||||
: title;
|
: title;
|
||||||
}
|
}
|
||||||
return 'New Chat';
|
return tr('chat.newChat');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab.type === 'import') {
|
if (tab.type === 'import') {
|
||||||
return importDefTitles.get(tab.id) || 'Import';
|
return importDefTitles.get(tab.id) || tr('activity.import');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab.type === 'metadata-diff') {
|
if (tab.type === 'metadata-diff') {
|
||||||
return 'Metadata Diff';
|
return tr('app.metadataDiff');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab.type === 'documentation') {
|
if (tab.type === 'documentation') {
|
||||||
return 'Documentation';
|
return tr('docs.title');
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Unknown';
|
return tr('tabBar.unknown');
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTabIcon = (tab: Tab): React.ReactNode => {
|
const getTabIcon = (tab: Tab): React.ReactNode => {
|
||||||
@@ -176,6 +178,7 @@ const ChevronRightIcon: React.FC = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const TabBar: React.FC = () => {
|
export const TabBar: React.FC = () => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
const {
|
const {
|
||||||
tabs,
|
tabs,
|
||||||
activeTabId,
|
activeTabId,
|
||||||
@@ -218,7 +221,7 @@ export const TabBar: React.FC = () => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = post.title || 'Untitled';
|
const title = post.title || tr('editor.untitled');
|
||||||
if (next.get(post.id) !== title) {
|
if (next.get(post.id) !== title) {
|
||||||
next.set(post.id, title);
|
next.set(post.id, title);
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -241,11 +244,11 @@ export const TabBar: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const post = await window.electronAPI?.posts.get(tab.id);
|
const post = await window.electronAPI?.posts.get(tab.id);
|
||||||
if (post) {
|
if (post) {
|
||||||
newTitles.set(tab.id, post.title || 'Untitled');
|
newTitles.set(tab.id, post.title || tr('editor.untitled'));
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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();
|
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
|
// Listen for post updates to refresh titles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -265,7 +268,7 @@ export const TabBar: React.FC = () => {
|
|||||||
if (post) {
|
if (post) {
|
||||||
setPostTitles(prev => {
|
setPostTitles(prev => {
|
||||||
const newTitles = new Map(prev);
|
const newTitles = new Map(prev);
|
||||||
newTitles.set(post.id, post.title || 'Untitled');
|
newTitles.set(post.id, post.title || tr('editor.untitled'));
|
||||||
return newTitles;
|
return newTitles;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -274,7 +277,7 @@ export const TabBar: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
unsub?.();
|
unsub?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [tr]);
|
||||||
|
|
||||||
// Fetch chat titles for chat tabs
|
// Fetch chat titles for chat tabs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -293,7 +296,7 @@ export const TabBar: React.FC = () => {
|
|||||||
newTitles.set(tab.id, conversation.title);
|
newTitles.set(tab.id, conversation.title);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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();
|
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
|
// Listen for chat title updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -336,7 +339,7 @@ export const TabBar: React.FC = () => {
|
|||||||
newTitles.set(tab.id, def.name);
|
newTitles.set(tab.id, def.name);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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();
|
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
|
// Listen for import definition name updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -411,7 +414,7 @@ export const TabBar: React.FC = () => {
|
|||||||
return changed ? updated : previous;
|
return changed ? updated : previous;
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [tabs, activeProject]);
|
}, [tabs, activeProject, tr]);
|
||||||
|
|
||||||
// Check if arrows are needed based on scroll position
|
// Check if arrows are needed based on scroll position
|
||||||
const updateArrowVisibility = useCallback(() => {
|
const updateArrowVisibility = useCallback(() => {
|
||||||
@@ -534,7 +537,7 @@ export const TabBar: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
className="tab-scroll-button tab-scroll-left"
|
className="tab-scroll-button tab-scroll-left"
|
||||||
onClick={scrollLeft}
|
onClick={scrollLeft}
|
||||||
title="Scroll tabs left"
|
title={tr('tabBar.scrollLeft')}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon />
|
<ChevronLeftIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -544,7 +547,7 @@ export const TabBar: React.FC = () => {
|
|||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = tab.id === activeTabId;
|
const isActive = tab.id === activeTabId;
|
||||||
const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id);
|
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);
|
const icon = getTabIcon(tab);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -555,7 +558,7 @@ export const TabBar: React.FC = () => {
|
|||||||
onClick={() => handleTabClick(tab.id)}
|
onClick={() => handleTabClick(tab.id)}
|
||||||
onDoubleClick={() => handleTabDoubleClick(tab)}
|
onDoubleClick={() => handleTabDoubleClick(tab)}
|
||||||
onMouseDown={(e) => handleMiddleClick(e, tab.id)}
|
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-icon">{icon}</span>
|
||||||
<span className={`tab-title ${tab.isTransient ? 'italic' : ''}`}>
|
<span className={`tab-title ${tab.isTransient ? 'italic' : ''}`}>
|
||||||
@@ -566,7 +569,7 @@ export const TabBar: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
className="tab-close"
|
className="tab-close"
|
||||||
onClick={(e) => handleTabClose(e, tab.id)}
|
onClick={(e) => handleTabClose(e, tab.id)}
|
||||||
title="Close (Ctrl+W)"
|
title={tr('tabBar.closeHint')}
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -580,7 +583,7 @@ export const TabBar: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
className="tab-scroll-button tab-scroll-right"
|
className="tab-scroll-button tab-scroll-right"
|
||||||
onClick={scrollRight}
|
onClick={scrollRight}
|
||||||
title="Scroll tabs right"
|
title={tr('tabBar.scrollRight')}
|
||||||
>
|
>
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import type { TaskProgress } from '../../../main/shared/electronApi';
|
import type { TaskProgress } from '../../../main/shared/electronApi';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './TaskPopup.css';
|
import './TaskPopup.css';
|
||||||
|
|
||||||
interface GroupedTaskEntry {
|
interface GroupedTaskEntry {
|
||||||
@@ -70,6 +71,7 @@ function buildTaskEntries(tasks: TaskProgress[]): TaskEntry[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TaskPopup: React.FC = () => {
|
export const TaskPopup: React.FC = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
const { tasks } = useAppStore();
|
const { tasks } = useAppStore();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||||
@@ -174,7 +176,7 @@ export const TaskPopup: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
className="task-cancel"
|
className="task-cancel"
|
||||||
onClick={() => handleCancel(task.taskId)}
|
onClick={() => handleCancel(task.taskId)}
|
||||||
title="Cancel task"
|
title={t('tasks.cancelTask')}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -216,57 +218,57 @@ export const TaskPopup: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
className={`task-popup-trigger ${hasActiveTasks ? 'active' : ''}`}
|
className={`task-popup-trigger ${hasActiveTasks ? 'active' : ''}`}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
title={`${runningTasks.length} running, ${pendingTasks.length} pending`}
|
title={t('tasks.triggerTitle', { running: runningTasks.length, pending: pendingTasks.length })}
|
||||||
>
|
>
|
||||||
{runningTasks.length > 0 ? (
|
{runningTasks.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<span className="task-spinner" />
|
<span className="task-spinner" />
|
||||||
<span>{runningTasks.length} running</span>
|
<span>{`${runningTasks.length} ${t('common.running')}`}</span>
|
||||||
</>
|
</>
|
||||||
) : pendingTasks.length > 0 ? (
|
) : pendingTasks.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<span className="task-icon pending">○</span>
|
<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>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="task-popup">
|
<div className="task-popup">
|
||||||
<div className="task-popup-header">
|
<div className="task-popup-header">
|
||||||
<h4>Background Tasks</h4>
|
<h4>{t('tasks.backgroundTasks')}</h4>
|
||||||
{recentTasks.length > 0 && (
|
{recentTasks.length > 0 && (
|
||||||
<button className="text-button" onClick={handleClearCompleted}>
|
<button className="text-button" onClick={handleClearCompleted}>
|
||||||
Clear completed
|
{t('tasks.clearCompleted')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{runningTasks.length > 0 && (
|
{runningTasks.length > 0 && (
|
||||||
<div className="task-section">
|
<div className="task-section">
|
||||||
<div className="task-section-title">Running</div>
|
<div className="task-section-title">{t('common.running')}</div>
|
||||||
{renderEntries(runningEntries)}
|
{renderEntries(runningEntries)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pendingTasks.length > 0 && (
|
{pendingTasks.length > 0 && (
|
||||||
<div className="task-section">
|
<div className="task-section">
|
||||||
<div className="task-section-title">Pending</div>
|
<div className="task-section-title">{t('common.pending')}</div>
|
||||||
{renderEntries(pendingEntries)}
|
{renderEntries(pendingEntries)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recentTasks.length > 0 && (
|
{recentTasks.length > 0 && (
|
||||||
<div className="task-section">
|
<div className="task-section">
|
||||||
<div className="task-section-title">Recent</div>
|
<div className="task-section-title">{t('tasks.recent')}</div>
|
||||||
{renderEntries(recentEntries)}
|
{renderEntries(recentEntries)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{runningTasks.length === 0 && pendingTasks.length === 0 && recentTasks.length === 0 && (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands';
|
import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands';
|
||||||
|
import { resolveSupportedUiLanguage, translateMenu } from '../../../main/shared/i18n';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
import './WindowTitleBar.css';
|
import './WindowTitleBar.css';
|
||||||
|
|
||||||
type WindowControlsOverlayLike = {
|
type WindowControlsOverlayLike = {
|
||||||
@@ -11,6 +13,7 @@ type WindowControlsOverlayLike = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const WindowTitleBar: React.FC = () => {
|
export const WindowTitleBar: React.FC = () => {
|
||||||
|
const { language } = useI18n();
|
||||||
const { sidebarVisible, panelVisible, toggleSidebar, togglePanel } = useAppStore();
|
const { sidebarVisible, panelVisible, toggleSidebar, togglePanel } = useAppStore();
|
||||||
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
|
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
|
||||||
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
|
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(() => {
|
const mnemonicByKey = useMemo(() => {
|
||||||
return visibleMenuGroups.reduce<Record<string, string>>((acc, group) => {
|
return visibleMenuGroups.reduce<Record<string, string>>((acc, group) => {
|
||||||
const mnemonicKey = group.label.charAt(0).toLowerCase();
|
const mnemonicKey = group.label.charAt(0).toLowerCase();
|
||||||
@@ -247,7 +263,7 @@ export const WindowTitleBar: React.FC = () => {
|
|||||||
const typed = event.key.toLowerCase();
|
const typed = event.key.toLowerCase();
|
||||||
const matchingIndices = actionableItems
|
const matchingIndices = actionableItems
|
||||||
.map((item, index) => ({ item, index }))
|
.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);
|
.map(entry => entry.index);
|
||||||
|
|
||||||
if (matchingIndices.length === 0) {
|
if (matchingIndices.length === 0) {
|
||||||
@@ -402,7 +418,7 @@ export const WindowTitleBar: React.FC = () => {
|
|||||||
onMouseEnter={(event) => handleMenuButtonMouseEnter(event, group.label)}
|
onMouseEnter={(event) => handleMenuButtonMouseEnter(event, group.label)}
|
||||||
aria-label={group.label}
|
aria-label={group.label}
|
||||||
>
|
>
|
||||||
{renderMenuLabel(group.label)}
|
{renderMenuLabel(getGroupDisplayLabel(group.label))}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -452,6 +468,7 @@ export const WindowTitleBar: React.FC = () => {
|
|||||||
return <div key={item.action} className="window-titlebar-menu-separator" />;
|
return <div key={item.action} className="window-titlebar-menu-separator" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayLabel = getItemDisplayLabel(item.label);
|
||||||
const acceleratorText = item.accelerator ? formatAccelerator(item.accelerator) : null;
|
const acceleratorText = item.accelerator ? formatAccelerator(item.accelerator) : null;
|
||||||
const actionableItems = activeMenu.items.filter(menuItem => !menuItem.separator);
|
const actionableItems = activeMenu.items.filter(menuItem => !menuItem.separator);
|
||||||
const currentActionableIndex = actionableItems.findIndex(menuItem => menuItem.action === item.action);
|
const currentActionableIndex = actionableItems.findIndex(menuItem => menuItem.action === item.action);
|
||||||
@@ -463,9 +480,9 @@ export const WindowTitleBar: React.FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
className={`window-titlebar-menu-item${isKeyboardActive ? ' is-keyboard-active' : ''}`}
|
className={`window-titlebar-menu-item${isKeyboardActive ? ' is-keyboard-active' : ''}`}
|
||||||
onClick={() => handleMenuItemClick(item.action)}
|
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>}
|
{acceleratorText && <span className="window-titlebar-menu-item-accelerator">{acceleratorText}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
108
src/renderer/i18n/index.tsx
Normal file
108
src/renderer/i18n/index.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import enJson from './locales/en.json';
|
||||||
|
import deJson from './locales/de.json';
|
||||||
|
import frJson from './locales/fr.json';
|
||||||
|
import itJson from './locales/it.json';
|
||||||
|
import esJson from './locales/es.json';
|
||||||
|
|
||||||
|
export type UiLanguage = 'en' | 'de' | 'fr' | 'it' | 'es';
|
||||||
|
|
||||||
|
type TranslationTable = Record<string, string>;
|
||||||
|
|
||||||
|
const en = enJson as TranslationTable;
|
||||||
|
const de = { ...en, ...(deJson as TranslationTable) };
|
||||||
|
const fr = { ...en, ...(frJson as TranslationTable) };
|
||||||
|
const it = { ...en, ...(itJson as TranslationTable) };
|
||||||
|
const es = { ...en, ...(esJson as TranslationTable) };
|
||||||
|
|
||||||
|
const uiCatalog: Record<UiLanguage, TranslationTable> = { en, de, fr, it, es };
|
||||||
|
|
||||||
|
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||||||
|
if (!params) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(params).reduce(
|
||||||
|
(result, [key, value]) => result.replaceAll(`{${key}}`, String(value)),
|
||||||
|
template
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSupportedUiLanguage(language: string | undefined | null): UiLanguage {
|
||||||
|
const normalized = (language || '').trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = normalized.split('-')[0];
|
||||||
|
if (base === 'de' || base === 'fr' || base === 'it' || base === 'es' || base === 'en') {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUiLanguageFromSystemLocale(systemLocale: string | undefined | null): UiLanguage {
|
||||||
|
return resolveSupportedUiLanguage(systemLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateUi(
|
||||||
|
language: UiLanguage,
|
||||||
|
key: string,
|
||||||
|
params?: Record<string, string | number>
|
||||||
|
): string {
|
||||||
|
const localized = uiCatalog[language]?.[key] ?? uiCatalog.en[key] ?? key;
|
||||||
|
return interpolate(localized, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface I18nContextValue {
|
||||||
|
language: UiLanguage;
|
||||||
|
t: (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue>({
|
||||||
|
language: 'en',
|
||||||
|
t: (key, params) => translateUi('en', key, params),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [language, setLanguage] = useState<UiLanguage>('en');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const detectLanguage = async () => {
|
||||||
|
try {
|
||||||
|
const systemLocale = await window.electronAPI?.app.getSystemLanguage?.();
|
||||||
|
const locale = systemLocale || navigator.language;
|
||||||
|
if (!cancelled) {
|
||||||
|
setLanguage(resolveUiLanguageFromSystemLocale(locale));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLanguage(resolveUiLanguageFromSystemLocale(navigator.language));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void detectLanguage();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<I18nContextValue>(
|
||||||
|
() => ({
|
||||||
|
language,
|
||||||
|
t: (key, params) => translateUi(language, key, params),
|
||||||
|
}),
|
||||||
|
[language]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useI18n(): I18nContextValue {
|
||||||
|
return useContext(I18nContext);
|
||||||
|
}
|
||||||
320
src/renderer/i18n/locales/de.json
Normal file
320
src/renderer/i18n/locales/de.json
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
{
|
||||||
|
"common.save": "Speichern",
|
||||||
|
"common.cancel": "Abbrechen",
|
||||||
|
"common.clear": "Leeren",
|
||||||
|
"common.settings": "Einstellungen",
|
||||||
|
"common.tasks": "Aufgaben",
|
||||||
|
"common.running": "laufend",
|
||||||
|
"common.pending": "ausstehend",
|
||||||
|
"activity.posts": "Beiträge",
|
||||||
|
"activity.pages": "Seiten",
|
||||||
|
"activity.media": "Medien",
|
||||||
|
"activity.tags": "Schlagwörter",
|
||||||
|
"activity.aiAssistant": "KI-Assistent",
|
||||||
|
"activity.import": "Importieren",
|
||||||
|
"activity.sourceControl": "Versionskontrolle",
|
||||||
|
"activity.toggleHint": "(erneut klicken, um die Seitenleiste umzuschalten)",
|
||||||
|
"tasks.backgroundTasks": "Hintergrundaufgaben",
|
||||||
|
"tasks.clearCompleted": "Abgeschlossene löschen",
|
||||||
|
"tasks.recent": "Zuletzt",
|
||||||
|
"tasks.noActive": "Keine aktiven Aufgaben",
|
||||||
|
"tasks.cancelTask": "Aufgabe abbrechen",
|
||||||
|
"tasks.triggerTitle": "{running} laufend, {pending} ausstehend",
|
||||||
|
"app.taskCompleted": "Aufgabe abgeschlossen: {message}",
|
||||||
|
"app.taskFailed": "Aufgabe fehlgeschlagen: {message}",
|
||||||
|
"app.databaseRebuildFailed": "Datenbank-Neuaufbau fehlgeschlagen",
|
||||||
|
"app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen",
|
||||||
|
"app.sitemapGenerationFailed": "Sitemap-Erstellung fehlgeschlagen",
|
||||||
|
"app.previewOpenFailed": "Ausgewählte Beitragsvorschau konnte nicht geöffnet werden",
|
||||||
|
"app.metadataDiff": "Metadaten-Diff",
|
||||||
|
"app.importComplete": "Import abgeschlossen: {posts} Beiträge, {media} Mediendateien",
|
||||||
|
"settings.language.english": "Englisch",
|
||||||
|
"settings.language.german": "Deutsch",
|
||||||
|
"settings.language.french": "Französisch",
|
||||||
|
"settings.language.italian": "Italienisch",
|
||||||
|
"settings.language.spanish": "Spanisch",
|
||||||
|
"settings.language.portuguese": "Portugiesisch (Português)",
|
||||||
|
"settings.language.dutch": "Niederländisch (Nederlands)",
|
||||||
|
"settings.language.polish": "Polnisch (Polski)",
|
||||||
|
"settings.language.russian": "Russisch (Русский)",
|
||||||
|
"settings.language.japanese": "Japanisch (日本語)",
|
||||||
|
"settings.language.chinese": "Chinesisch (中文)",
|
||||||
|
"settings.language.korean": "Koreanisch (한국어)",
|
||||||
|
"settings.language.arabic": "Arabisch (العربية)",
|
||||||
|
"settings.language.hindi": "Hindi",
|
||||||
|
"settings.language.turkish": "Türkisch (Türkçe)",
|
||||||
|
"settings.language.swedish": "Schwedisch (Svenska)",
|
||||||
|
"settings.language.danish": "Dänisch (Dansk)",
|
||||||
|
"settings.language.norwegian": "Norwegisch (Norsk)",
|
||||||
|
"settings.language.finnish": "Finnisch (Suomi)",
|
||||||
|
"settings.language.czech": "Tschechisch (Čeština)",
|
||||||
|
"settings.project.title": "Projekt",
|
||||||
|
"settings.project.browse": "Durchsuchen",
|
||||||
|
"settings.project.reset": "Zurücksetzen",
|
||||||
|
"settings.project.resetDefault": "Auf Standard zurücksetzen",
|
||||||
|
"settings.project.selectDataFolder": "Projekt-Datenordner auswählen",
|
||||||
|
"settings.editor.title": "Texteditor",
|
||||||
|
"settings.editor.mode.wysiwyg": "WYSIWYG (Visueller Editor)",
|
||||||
|
"settings.editor.mode.markdown": "Markdown (Quelle)",
|
||||||
|
"settings.editor.mode.preview": "Vorschau (schreibgeschützt)",
|
||||||
|
"settings.editor.diff.inline": "Zeilenweise",
|
||||||
|
"settings.editor.diff.sideBySide": "Nebeneinander",
|
||||||
|
"settings.content.title": "Beitragskategorien",
|
||||||
|
"settings.content.renderInLists": "In Listen anzeigen",
|
||||||
|
"settings.content.showTitles": "Titel anzeigen",
|
||||||
|
"settings.ai.title": "KI-Assistent",
|
||||||
|
"settings.ai.noModels": "Keine Modelle verfügbar",
|
||||||
|
"settings.publishing.ftpTitle": "FTP-Veröffentlichung",
|
||||||
|
"settings.publishing.sshTitle": "SSH-Veröffentlichung",
|
||||||
|
"settings.data.title": "Datenbankwartung",
|
||||||
|
"settings.data.fileSystemTitle": "Dateisystem",
|
||||||
|
"settings.search.placeholder": "Einstellungen durchsuchen...",
|
||||||
|
"settings.search.noResults": "No Einstellungen found matching \"{query}\"",
|
||||||
|
"settings.search.clear": "Suche löschen",
|
||||||
|
"settings.toast.publishingSaved": "Veröffentlichungs-Anmeldedaten gespeichert",
|
||||||
|
"settings.toast.saveCredentialsFailed": "Fehler beim save credentials",
|
||||||
|
"settings.toast.credentialsCleared": "{type}-Anmeldedaten gelöscht",
|
||||||
|
"settings.toast.projectSaved": "Project Einstellungen saved",
|
||||||
|
"settings.toast.projectSaveFailed": "Fehler beim save project Einstellungen",
|
||||||
|
"settings.toast.categoryAdded": "Kategorie \"{category}\" hinzugefügt",
|
||||||
|
"settings.toast.categoryAddFailed": "Fehler beim add category",
|
||||||
|
"settings.toast.categoryExists": "Kategorie existiert bereits",
|
||||||
|
"settings.toast.categoryProtected": "Standardkategorie \"{category}\" kann nicht gelöscht werden",
|
||||||
|
"settings.toast.categoryAtLeastOne": "Mindestens eine Kategorie ist erforderlich",
|
||||||
|
"settings.toast.categoryRemoved": "Kategorie \"{category}\" entfernt",
|
||||||
|
"settings.toast.categoryRemoveFailed": "Fehler beim remove category",
|
||||||
|
"settings.toast.categoriesReset": "Kategorien auf Standard zurückgesetzt",
|
||||||
|
"settings.toast.categoriesResetFailed": "Fehler beim reset categories",
|
||||||
|
"settings.toast.categorySettingsUpdateFailed": "Fehler beim update category Einstellungen",
|
||||||
|
"settings.toast.systemPromptSaved": "System-Prompt gespeichert",
|
||||||
|
"settings.toast.systemPromptSaveFailed": "Fehler beim save system prompt",
|
||||||
|
"settings.toast.systemPromptReset": "System-Prompt auf Standard zurückgesetzt",
|
||||||
|
"settings.toast.systemPromptResetFailed": "Fehler beim reset system prompt",
|
||||||
|
"settings.toast.apiKeySaved": "API-Schlüssel gespeichert und validiert",
|
||||||
|
"settings.toast.apiKeyInvalid": "Ungültiger API-Schlüssel",
|
||||||
|
"settings.toast.apiKeySaveFailed": "Fehler beim save API key",
|
||||||
|
"settings.toast.defaultModelUpdated": "Standardmodell aktualisiert",
|
||||||
|
"settings.toast.defaultModelUpdateFailed": "Fehler beim set default model",
|
||||||
|
"settings.toast.rebuildPostsLoading": "Rebuilding Beiträge database...",
|
||||||
|
"settings.toast.rebuildPostsSuccess": "Beitragsdatenbank neu aufgebaut",
|
||||||
|
"settings.toast.rebuildPostsFailed": "Fehler beim rebuild Beiträge database",
|
||||||
|
"settings.toast.rebuildMediaLoading": "Rebuilding Medien database...",
|
||||||
|
"settings.toast.rebuildMediaSuccess": "Mediendatenbank neu aufgebaut",
|
||||||
|
"settings.toast.rebuildMediaFailed": "Fehler beim rebuild Medien database",
|
||||||
|
"settings.toast.rebuildLinksLoading": "Rebuilding Beitrag links...",
|
||||||
|
"settings.toast.rebuildLinksSuccess": "Beitragslinks neu aufgebaut",
|
||||||
|
"settings.toast.rebuildLinksFailed": "Fehler beim rebuild Beitrag links",
|
||||||
|
"settings.toast.thumbnailsLoading": "Vorschaubilder werden erzeugt...",
|
||||||
|
"settings.toast.thumbnailsGenerated": "{count} Vorschaubilder erzeugt",
|
||||||
|
"settings.toast.thumbnailsAlreadyExist": "Alle Vorschaubilder existieren bereits",
|
||||||
|
"settings.toast.thumbnailsComplete": "Generierung der Vorschaubilder abgeschlossen",
|
||||||
|
"settings.toast.thumbnailsFailed": "Fehler beim generate thumbnails",
|
||||||
|
"chat.setupTitle": "KI-Chat-Einrichtung",
|
||||||
|
"chat.apiKeyRequiredTitle": "OpenCode Zen API-Schlüssel erforderlich",
|
||||||
|
"chat.apiKeyRequiredDescription": "Gib deinen OpenCode API-Schlüssel ein, um den KI-Chat zu aktivieren.",
|
||||||
|
"chat.apiKeyPlaceholder": "API-Schlüssel eingeben...",
|
||||||
|
"chat.apiKeySave": "Schlüssel speichern",
|
||||||
|
"chat.apiKeyValidating": "Wird validiert...",
|
||||||
|
"chat.apiKeyInvalid": "Ungültiger API-Schlüssel. Bitte prüfen und erneut versuchen.",
|
||||||
|
"chat.apiKeyValidationFailed": "Fehler beim validate API key.",
|
||||||
|
"chat.newChat": "Neuer Chat",
|
||||||
|
"chat.welcomeTitle": "Willkommen beim KI-Assistenten",
|
||||||
|
"chat.welcomeDescription": "I can help you manage your Beiträge and Medien. Try asking me to:",
|
||||||
|
"chat.welcomeTipSearch": "Suche for Beiträge about a specific topic",
|
||||||
|
"chat.welcomeTipDetails": "Get details about a specific Beitrag",
|
||||||
|
"chat.welcomeTipTags": "Alle Tags oder Kategorien in deinem Blog auflisten",
|
||||||
|
"chat.welcomeTipMetadata": "Update metadata for Beiträge or Medien",
|
||||||
|
"chat.welcomeTipImages": "List all images in your Medien library",
|
||||||
|
"chat.role.you": "Du",
|
||||||
|
"chat.role.assistant": "Assistent",
|
||||||
|
"chat.stop": "Stopp",
|
||||||
|
"chat.inputPlaceholder": "Nachricht eingeben...",
|
||||||
|
"chat.errorPrefix": "Fehler: {error}",
|
||||||
|
"chat.errorNoResponse": "Fehler beim get a response. Please try again.",
|
||||||
|
"chat.errorEmptyResponse": "Das Modell hat eine leere Antwort zurückgegeben. Versuche ein anderes Modell oder formuliere deine Frage neu.",
|
||||||
|
"chat.errorGeneric": "Sorry, an error occurred while processing your Nachricht.",
|
||||||
|
"chat.cancelledSuffix": "(abgebrochen)",
|
||||||
|
"aiSuggestions.title": "KI-Bildanalyse",
|
||||||
|
"aiSuggestions.close": "Schließen",
|
||||||
|
"aiSuggestions.analyzing": "Bild wird analysiert...",
|
||||||
|
"aiSuggestions.titleField": "Titel",
|
||||||
|
"aiSuggestions.altField": "Alternativtext",
|
||||||
|
"aiSuggestions.captionField": "Bildunterschrift",
|
||||||
|
"aiSuggestions.hasExisting": "(hat vorhandenen Wert)",
|
||||||
|
"aiSuggestions.current": "Aktuell",
|
||||||
|
"aiSuggestions.intro": "Wähle aus, welche KI-generierten Werte übernommen werden sollen. Vorhandene Werte bleiben standardmäßig erhalten.",
|
||||||
|
"aiSuggestions.empty": "Für dieses Bild wurden keine Vorschläge erstellt.",
|
||||||
|
"aiSuggestions.wait": "Bitte warten...",
|
||||||
|
"aiSuggestions.applySelected": "Ausgewählte übernehmen",
|
||||||
|
"insert.title.link": "Link einfügen",
|
||||||
|
"insert.title.image": "Bild einfügen",
|
||||||
|
"insert.tab.linkInternal": "Mit Beitrag verlinken",
|
||||||
|
"insert.tab.imageInternal": "Mediathek",
|
||||||
|
"insert.tab.linkExternal": "Externe URL",
|
||||||
|
"insert.tab.imageExternal": "Externes Bild",
|
||||||
|
"insert.searchPlaceholder.link": "Suche Beiträge by title or content...",
|
||||||
|
"insert.searchPlaceholder.image": "Suche Medien by name, title, or alt text...",
|
||||||
|
"insert.status.searching": "Suche...",
|
||||||
|
"insert.status.typeMore": "Zum Suchen mindestens 2 Zeichen eingeben",
|
||||||
|
"insert.status.noResults": "Keine {kind} für \"{query}\" gefunden",
|
||||||
|
"insert.label.url": "Webadresse",
|
||||||
|
"insert.label.linkTextOptional": "Linktext (optional)",
|
||||||
|
"insert.label.altText": "Alternativtext",
|
||||||
|
"insert.placeholder.linkUrl": "https://beispiel.de",
|
||||||
|
"insert.placeholder.imageUrl": "https://beispiel.de/bild.jpg",
|
||||||
|
"insert.placeholder.linkText": "Hier klicken",
|
||||||
|
"insert.placeholder.imageAlt": "Beschreibung des Bildes",
|
||||||
|
"insert.submit.link": "Link einfügen",
|
||||||
|
"insert.submit.image": "Bild einfügen",
|
||||||
|
"insert.hint.internal": "Mit ↑↓ navigieren, Enter zum Auswählen, Esc zum Schließen",
|
||||||
|
"insert.hint.external": "URL eingeben und Enter drücken oder auf die Schaltfläche klicken, Esc zum Schließen",
|
||||||
|
"insert.hint.canonicalPost": "Kanonisch: /YYYY/MM/DD/slug",
|
||||||
|
"insert.hint.canonicalMedia": "Canonical: /Medien/YYYY/MM/file.ext",
|
||||||
|
"postLinks.loading": "Links werden geladen...",
|
||||||
|
"postLinks.link": "Link",
|
||||||
|
"postLinks.links": "Links",
|
||||||
|
"postLinks.linksTo": "Verlinkt auf ({count})",
|
||||||
|
"postLinks.linkedBy": "Verlinkt von ({count})",
|
||||||
|
"postLinks.openTitle": "Öffnen: {title}",
|
||||||
|
"docs.title": "Dokumentation",
|
||||||
|
"docs.subtitle": "Benutzerhandbuch für diese installierte bDS-Version.",
|
||||||
|
"gitDiff.header": "Unterschied: {target}",
|
||||||
|
"gitDiff.noProject": "Kein aktives Projekt ausgewählt.",
|
||||||
|
"gitDiff.noProjectPath": "Projektpfad konnte nicht ermittelt werden.",
|
||||||
|
"gitDiff.loadFailed": "Fehler beim load diff.",
|
||||||
|
"gitDiff.loading": "Diff wird geladen...",
|
||||||
|
"gitDiff.changedFiles": "Geänderte Dateien",
|
||||||
|
"gitDiff.previousFile": "Vorherige Datei",
|
||||||
|
"gitDiff.nextFile": "Nächste Datei",
|
||||||
|
"errorModal.error": "Fehler",
|
||||||
|
"errorModal.stackTrace": "Stack-Trace",
|
||||||
|
"errorModal.copyClipboard": "In Zwischenablage kopieren",
|
||||||
|
"errorModal.copy": "Kopieren",
|
||||||
|
"errorModal.noStack": "Kein Stack-Trace verfügbar",
|
||||||
|
"confirmDelete.title": "Löschen bestätigen",
|
||||||
|
"confirmDelete.promptPost": "Are you sure you want to delete the Beitrag",
|
||||||
|
"confirmDelete.promptMedia": "Are you sure you want to delete the Medien file",
|
||||||
|
"confirmDelete.warning": "Warnung:",
|
||||||
|
"confirmDelete.referencedBy": "Diese(r) {itemType} wird von folgenden Elementen referenziert:",
|
||||||
|
"confirmDelete.note": "Beim Löschen dieses/dieser {itemType} werden alle diese Verweise entfernt.",
|
||||||
|
"confirmDelete.cancel": "Abbrechen",
|
||||||
|
"confirmDelete.deletePost": "Beitrag löschen",
|
||||||
|
"confirmDelete.deleteMedia": "Medien löschen",
|
||||||
|
"confirmDelete.itemType.post": "Beitrag",
|
||||||
|
"confirmDelete.itemType.media": "Medien",
|
||||||
|
"lightbox.close": "Schließen (Esc)",
|
||||||
|
"lightbox.previous": "Vorheriges (←)",
|
||||||
|
"lightbox.next": "Nächstes (→)",
|
||||||
|
"credentials.error.load": "Fehler beim load credentials:",
|
||||||
|
"credentials.error.save": "Fehler beim save credentials:",
|
||||||
|
"credentials.toast.saved": "Anmeldedaten gespeichert",
|
||||||
|
"credentials.toast.saveFailed": "Fehler beim save credentials",
|
||||||
|
"credentials.toast.testing": "{type}-Verbindung wird getestet...",
|
||||||
|
"credentials.toast.connectionFailed": "Connection fehlgeschlagen - check credentials",
|
||||||
|
"credentials.tab.ftp": "FTP-Zugang",
|
||||||
|
"credentials.tab.ssh": "SSH-Zugang",
|
||||||
|
"credentials.ftp.title": "FTP-Veröffentlichung",
|
||||||
|
"credentials.ftp.description": "Konfiguriere FTP, um deinen Blog auf einem Webserver zu veröffentlichen.",
|
||||||
|
"credentials.ssh.title": "SSH-Veröffentlichung",
|
||||||
|
"credentials.ssh.description": "Konfiguriere SSH für eine sichere Veröffentlichung auf deinem Server.",
|
||||||
|
"credentials.field.host": "Server",
|
||||||
|
"credentials.field.username": "Benutzername",
|
||||||
|
"credentials.field.password": "Passwort",
|
||||||
|
"credentials.field.sshKeyPath": "SSH-Schlüsselpfad",
|
||||||
|
"credentials.action.testConnection": "Verbindung testen",
|
||||||
|
"credentials.ftp.placeholder.host": "ftp.beispiel.de",
|
||||||
|
"credentials.ftp.placeholder.username": "ftp-benutzer",
|
||||||
|
"credentials.ftp.placeholder.password": "Passwort",
|
||||||
|
"credentials.ssh.placeholder.host": "server.beispiel.de",
|
||||||
|
"credentials.ssh.placeholder.username": "ssh-benutzer",
|
||||||
|
"credentials.ssh.placeholder.keyPath": "~/.ssh/mein_schluessel",
|
||||||
|
"gitSidebar.header": "QUELLSTEUERUNG",
|
||||||
|
"gitSidebar.loading": "Laden...",
|
||||||
|
"gitSidebar.error.fetchRemoteUpdates": "Fehler beim fetch remote updates.",
|
||||||
|
"gitSidebar.error.refreshRemoteState": "Remote-Tracking-Status konnte nicht aktualisiert werden.",
|
||||||
|
"gitSidebar.error.gitMissing": "Git-Programm nicht gefunden. Bitte installiere Git und starte die App neu.",
|
||||||
|
"gitSidebar.error.noActiveProject": "Kein aktives Projekt ausgewählt.",
|
||||||
|
"gitSidebar.error.loadRepoStatus": "Repository-Status konnte nicht geladen werden.",
|
||||||
|
"gitSidebar.error.initFailed": "Fehler beim initialize git repository.",
|
||||||
|
"gitSidebar.error.actionFailed": "Fehler beim {action}.",
|
||||||
|
"gitSidebar.error.commitFailed": "Fehler beim commit changes.",
|
||||||
|
"gitSidebar.progress.preparingInit": "Repository-Initialisierung wird vorbereitet...",
|
||||||
|
"gitSidebar.progress.pushingRemote": "Commits werden zum Remote übertragen... das kann bei großen Uploads eine Weile dauern.",
|
||||||
|
"gitSidebar.progress.fetching": "Remote-Aktualisierungen werden abgerufen...",
|
||||||
|
"gitSidebar.progress.pulling": "Neueste Änderungen werden gezogen...",
|
||||||
|
"gitSidebar.progress.pruningLfs": "Lokaler Git-LFS-Cache wird bereinigt...",
|
||||||
|
"gitSidebar.progress.committing": "Commit wird erstellt...",
|
||||||
|
"gitSidebar.progress.initializingRepo": "Repository wird initialisiert...",
|
||||||
|
"gitSidebar.history.synced": "Synchronisiert",
|
||||||
|
"gitSidebar.history.localOnly": "Nur lokal",
|
||||||
|
"gitSidebar.history.remoteOnly": "Nur remote",
|
||||||
|
"gitSidebar.init.transcript": "Initialisierungsprotokoll",
|
||||||
|
"gitSidebar.aria.repoActions": "Repository-Aktionen",
|
||||||
|
"gitSidebar.aria.openChanges": "Offene Änderungen",
|
||||||
|
"gitSidebar.aria.commitStatusLegend": "Legende zum Commit-Status",
|
||||||
|
"gitSidebar.aria.versionHistory": "Versionsverlauf",
|
||||||
|
"gitSidebar.action.fetch": "Abrufen",
|
||||||
|
"gitSidebar.action.fetching": "Abrufen...",
|
||||||
|
"gitSidebar.action.pull": "Pullen",
|
||||||
|
"gitSidebar.action.pulling": "Pullen...",
|
||||||
|
"gitSidebar.action.push": "Pushen",
|
||||||
|
"gitSidebar.action.pushing": "Pushen...",
|
||||||
|
"gitSidebar.action.pruneLfs": "LFS bereinigen",
|
||||||
|
"gitSidebar.action.pruning": "Bereinigen...",
|
||||||
|
"gitSidebar.action.commit": "Commit erstellen",
|
||||||
|
"gitSidebar.action.committing": "Commit wird erstellt...",
|
||||||
|
"gitSidebar.action.initializeGit": "Git initialisieren",
|
||||||
|
"gitSidebar.action.initializing": "Initialisieren...",
|
||||||
|
"gitSidebar.openChanges": "Öffnen Changes ({count})",
|
||||||
|
"gitSidebar.versionHistory": "Versionsverlauf ({count})",
|
||||||
|
"gitSidebar.loadingChanges": "Änderungen werden geladen...",
|
||||||
|
"gitSidebar.noChanges": "Keine Änderungen",
|
||||||
|
"gitSidebar.loadingHistory": "Verlauf wird geladen...",
|
||||||
|
"gitSidebar.noCommits": "Noch keine Commits",
|
||||||
|
"gitSidebar.branch": "Zweig: {branch}",
|
||||||
|
"gitSidebar.aheadBehind": "voraus {ahead} / hinterher {behind}",
|
||||||
|
"gitSidebar.notRepo": "Dieses Projekt ist kein Git-Repository.",
|
||||||
|
"gitSidebar.placeholder.remoteUrl": "Optionale URL des Remote-Repositorys",
|
||||||
|
"gitSidebar.placeholder.commitMessage": "Commit-Nachricht",
|
||||||
|
"editor.untitled": "Unbenannt",
|
||||||
|
"tabBar.style": "Stil",
|
||||||
|
"tabBar.loading": "Laden...",
|
||||||
|
"tabBar.unknown": "Unbekannt",
|
||||||
|
"tabBar.preview": "Vorschau",
|
||||||
|
"tabBar.modified": "Geändert",
|
||||||
|
"tabBar.closeHint": "Schließen (Ctrl+W)",
|
||||||
|
"tabBar.scrollLeft": "Tabs nach links scrollen",
|
||||||
|
"tabBar.scrollRight": "Tabs nach rechts scrollen",
|
||||||
|
"tabBar.commitTitle": "Änderung {hash}",
|
||||||
|
"tabBar.error.fetchPostTitle": "Fehler beim fetch Beitrag title:",
|
||||||
|
"tabBar.error.fetchChatTitle": "Fehler beim fetch chat title:",
|
||||||
|
"tabBar.error.fetchImportTitle": "Fehler beim fetch import definition title:",
|
||||||
|
"tabBar.error.fetchCommitTitle": "Fehler beim fetch commit titles:",
|
||||||
|
"metadataDiff.title": "Metadaten-Diff-Werkzeug",
|
||||||
|
"metadataDiff.description": "Compare Beitrag metadata between database and markdown files. Fix inconsistencies caused by bugs or manual edits.",
|
||||||
|
"metadataDiff.error.loadStats": "Fehler beim load database statistics",
|
||||||
|
"metadataDiff.error.scan": "Fehler beim scan for differences",
|
||||||
|
"metadataDiff.progress.starting": "Scan wird gestartet...",
|
||||||
|
"metadataDiff.progress.scanningPublished": "Scanning published Beiträge...",
|
||||||
|
"metadataDiff.progress.scanning": "Scanne...",
|
||||||
|
"metadataDiff.action.scan": "Nach Unterschieden suchen",
|
||||||
|
"metadataDiff.action.rescan": "Erneut scannen",
|
||||||
|
"metadataDiff.stats.totalPosts": "Beiträge gesamt",
|
||||||
|
"metadataDiff.stats.published": "Veröffentlicht",
|
||||||
|
"metadataDiff.stats.drafts": "Entwürfe",
|
||||||
|
"metadataDiff.stats.mediaFiles": "Mediendateien",
|
||||||
|
"metadataDiff.summary.noDiffs": "✅ No differences found! All {total} published Beiträge are in sync.",
|
||||||
|
"metadataDiff.summary.withDiffs": "⚠️ Found {count} Beiträge with differences out of {total} published Beiträge.",
|
||||||
|
"metadataDiff.group.differences": "{label}-Unterschiede",
|
||||||
|
"metadataDiff.group.postsCount": "{count} Beiträge",
|
||||||
|
"metadataDiff.sync.failed": "fehlgeschlagen",
|
||||||
|
"metadataDiff.sync.dbToFile.title": "Dateien mit Datenbankwerten aktualisieren",
|
||||||
|
"metadataDiff.sync.dbToFile.success": "Synced {success} Beiträge to files{fehlgeschlagen}",
|
||||||
|
"metadataDiff.sync.dbToFile.error": "Fehler beim sync to files",
|
||||||
|
"metadataDiff.sync.fileToDb.title": "Datenbank mit Dateiwerten aktualisieren",
|
||||||
|
"metadataDiff.sync.fileToDb.success": "Synced {success} files to database{fehlgeschlagen}",
|
||||||
|
"metadataDiff.sync.fileToDb.error": "Fehler beim sync to database",
|
||||||
|
"metadataDiff.value.database": "Datenbank",
|
||||||
|
"metadataDiff.value.file": "Datei",
|
||||||
|
"metadataDiff.empty": "Klicke auf „Nach Unterschieden suchen“, um Datenbank-Metadaten mit Datei-Metadaten zu vergleichen."
|
||||||
|
}
|
||||||
320
src/renderer/i18n/locales/en.json
Normal file
320
src/renderer/i18n/locales/en.json
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
{
|
||||||
|
"common.save": "Save",
|
||||||
|
"common.cancel": "Cancel",
|
||||||
|
"common.clear": "Clear",
|
||||||
|
"common.settings": "Settings",
|
||||||
|
"common.tasks": "Tasks",
|
||||||
|
"common.running": "running",
|
||||||
|
"common.pending": "pending",
|
||||||
|
"activity.posts": "Posts",
|
||||||
|
"activity.pages": "Pages",
|
||||||
|
"activity.media": "Media",
|
||||||
|
"activity.tags": "Tags",
|
||||||
|
"activity.aiAssistant": "AI Assistant",
|
||||||
|
"activity.import": "Import",
|
||||||
|
"activity.sourceControl": "Source Control",
|
||||||
|
"activity.toggleHint": "(click again to toggle sidebar)",
|
||||||
|
"tasks.backgroundTasks": "Background Tasks",
|
||||||
|
"tasks.clearCompleted": "Clear completed",
|
||||||
|
"tasks.recent": "Recent",
|
||||||
|
"tasks.noActive": "No active tasks",
|
||||||
|
"tasks.cancelTask": "Cancel task",
|
||||||
|
"tasks.triggerTitle": "{running} running, {pending} pending",
|
||||||
|
"app.taskCompleted": "Task completed: {message}",
|
||||||
|
"app.taskFailed": "Task failed: {message}",
|
||||||
|
"app.databaseRebuildFailed": "Database rebuild failed",
|
||||||
|
"app.textReindexFailed": "Text reindex failed",
|
||||||
|
"app.sitemapGenerationFailed": "Sitemap generation failed",
|
||||||
|
"app.previewOpenFailed": "Failed to open selected post preview",
|
||||||
|
"app.metadataDiff": "Metadata Diff",
|
||||||
|
"app.importComplete": "Import complete: {posts} posts, {media} media files",
|
||||||
|
"settings.language.english": "English",
|
||||||
|
"settings.language.german": "German",
|
||||||
|
"settings.language.french": "French",
|
||||||
|
"settings.language.italian": "Italian",
|
||||||
|
"settings.language.spanish": "Spanish",
|
||||||
|
"settings.language.portuguese": "Portuguese (Português)",
|
||||||
|
"settings.language.dutch": "Dutch (Nederlands)",
|
||||||
|
"settings.language.polish": "Polish (Polski)",
|
||||||
|
"settings.language.russian": "Russian (Русский)",
|
||||||
|
"settings.language.japanese": "Japanese (日本語)",
|
||||||
|
"settings.language.chinese": "Chinese (中文)",
|
||||||
|
"settings.language.korean": "Korean (한국어)",
|
||||||
|
"settings.language.arabic": "Arabic (العربية)",
|
||||||
|
"settings.language.hindi": "Hindi (हिन्दी)",
|
||||||
|
"settings.language.turkish": "Turkish (Türkçe)",
|
||||||
|
"settings.language.swedish": "Swedish (Svenska)",
|
||||||
|
"settings.language.danish": "Danish (Dansk)",
|
||||||
|
"settings.language.norwegian": "Norwegian (Norsk)",
|
||||||
|
"settings.language.finnish": "Finnish (Suomi)",
|
||||||
|
"settings.language.czech": "Czech (Čeština)",
|
||||||
|
"settings.project.title": "Project",
|
||||||
|
"settings.project.browse": "Browse",
|
||||||
|
"settings.project.reset": "Reset",
|
||||||
|
"settings.project.resetDefault": "Reset to default",
|
||||||
|
"settings.project.selectDataFolder": "Select Project Data Folder",
|
||||||
|
"settings.editor.title": "Editor",
|
||||||
|
"settings.editor.mode.wysiwyg": "WYSIWYG (Visual Editor)",
|
||||||
|
"settings.editor.mode.markdown": "Markdown (Source)",
|
||||||
|
"settings.editor.mode.preview": "Preview (Read-only)",
|
||||||
|
"settings.editor.diff.inline": "Inline",
|
||||||
|
"settings.editor.diff.sideBySide": "Side by Side",
|
||||||
|
"settings.content.title": "Post Categories",
|
||||||
|
"settings.content.renderInLists": "Render in lists",
|
||||||
|
"settings.content.showTitles": "Show titles",
|
||||||
|
"settings.ai.title": "AI Assistant",
|
||||||
|
"settings.ai.noModels": "No models available",
|
||||||
|
"settings.publishing.ftpTitle": "FTP Publishing",
|
||||||
|
"settings.publishing.sshTitle": "SSH Publishing",
|
||||||
|
"settings.data.title": "Database Maintenance",
|
||||||
|
"settings.data.fileSystemTitle": "File System",
|
||||||
|
"settings.search.placeholder": "Search settings...",
|
||||||
|
"settings.search.noResults": "No settings found matching \"{query}\"",
|
||||||
|
"settings.search.clear": "Clear search",
|
||||||
|
"settings.toast.publishingSaved": "Publishing credentials saved",
|
||||||
|
"settings.toast.saveCredentialsFailed": "Failed to save credentials",
|
||||||
|
"settings.toast.credentialsCleared": "{type} credentials cleared",
|
||||||
|
"settings.toast.projectSaved": "Project settings saved",
|
||||||
|
"settings.toast.projectSaveFailed": "Failed to save project settings",
|
||||||
|
"settings.toast.categoryAdded": "Category \"{category}\" added",
|
||||||
|
"settings.toast.categoryAddFailed": "Failed to add category",
|
||||||
|
"settings.toast.categoryExists": "Category already exists",
|
||||||
|
"settings.toast.categoryProtected": "Cannot delete standard category \"{category}\"",
|
||||||
|
"settings.toast.categoryAtLeastOne": "Must have at least one category",
|
||||||
|
"settings.toast.categoryRemoved": "Category \"{category}\" removed",
|
||||||
|
"settings.toast.categoryRemoveFailed": "Failed to remove category",
|
||||||
|
"settings.toast.categoriesReset": "Categories reset to defaults",
|
||||||
|
"settings.toast.categoriesResetFailed": "Failed to reset categories",
|
||||||
|
"settings.toast.categorySettingsUpdateFailed": "Failed to update category settings",
|
||||||
|
"settings.toast.systemPromptSaved": "System prompt saved",
|
||||||
|
"settings.toast.systemPromptSaveFailed": "Failed to save system prompt",
|
||||||
|
"settings.toast.systemPromptReset": "System prompt reset to default",
|
||||||
|
"settings.toast.systemPromptResetFailed": "Failed to reset system prompt",
|
||||||
|
"settings.toast.apiKeySaved": "API key saved and validated",
|
||||||
|
"settings.toast.apiKeyInvalid": "Invalid API key",
|
||||||
|
"settings.toast.apiKeySaveFailed": "Failed to save API key",
|
||||||
|
"settings.toast.defaultModelUpdated": "Default model updated",
|
||||||
|
"settings.toast.defaultModelUpdateFailed": "Failed to set default model",
|
||||||
|
"settings.toast.rebuildPostsLoading": "Rebuilding posts database...",
|
||||||
|
"settings.toast.rebuildPostsSuccess": "Posts database rebuilt",
|
||||||
|
"settings.toast.rebuildPostsFailed": "Failed to rebuild posts database",
|
||||||
|
"settings.toast.rebuildMediaLoading": "Rebuilding media database...",
|
||||||
|
"settings.toast.rebuildMediaSuccess": "Media database rebuilt",
|
||||||
|
"settings.toast.rebuildMediaFailed": "Failed to rebuild media database",
|
||||||
|
"settings.toast.rebuildLinksLoading": "Rebuilding post links...",
|
||||||
|
"settings.toast.rebuildLinksSuccess": "Post links rebuilt",
|
||||||
|
"settings.toast.rebuildLinksFailed": "Failed to rebuild post links",
|
||||||
|
"settings.toast.thumbnailsLoading": "Generating thumbnails...",
|
||||||
|
"settings.toast.thumbnailsGenerated": "Generated {count} thumbnails",
|
||||||
|
"settings.toast.thumbnailsAlreadyExist": "All thumbnails already exist",
|
||||||
|
"settings.toast.thumbnailsComplete": "Thumbnail generation complete",
|
||||||
|
"settings.toast.thumbnailsFailed": "Failed to generate thumbnails",
|
||||||
|
"chat.setupTitle": "AI Chat Setup",
|
||||||
|
"chat.apiKeyRequiredTitle": "OpenCode Zen API Key Required",
|
||||||
|
"chat.apiKeyRequiredDescription": "Enter your OpenCode API key to enable AI chat.",
|
||||||
|
"chat.apiKeyPlaceholder": "Enter your API key...",
|
||||||
|
"chat.apiKeySave": "Save Key",
|
||||||
|
"chat.apiKeyValidating": "Validating...",
|
||||||
|
"chat.apiKeyInvalid": "Invalid API key. Please check and try again.",
|
||||||
|
"chat.apiKeyValidationFailed": "Failed to validate API key.",
|
||||||
|
"chat.newChat": "New Chat",
|
||||||
|
"chat.welcomeTitle": "Welcome to the AI Assistant",
|
||||||
|
"chat.welcomeDescription": "I can help you manage your posts and media. Try asking me to:",
|
||||||
|
"chat.welcomeTipSearch": "Search for posts about a specific topic",
|
||||||
|
"chat.welcomeTipDetails": "Get details about a specific post",
|
||||||
|
"chat.welcomeTipTags": "List all tags or categories in your blog",
|
||||||
|
"chat.welcomeTipMetadata": "Update metadata for posts or media",
|
||||||
|
"chat.welcomeTipImages": "List all images in your media library",
|
||||||
|
"chat.role.you": "You",
|
||||||
|
"chat.role.assistant": "Assistant",
|
||||||
|
"chat.stop": "Stop",
|
||||||
|
"chat.inputPlaceholder": "Type a message...",
|
||||||
|
"chat.errorPrefix": "Error: {error}",
|
||||||
|
"chat.errorNoResponse": "Failed to get a response. Please try again.",
|
||||||
|
"chat.errorEmptyResponse": "The model returned an empty response. Try a different model or rephrase your question.",
|
||||||
|
"chat.errorGeneric": "Sorry, an error occurred while processing your message.",
|
||||||
|
"chat.cancelledSuffix": "(cancelled)",
|
||||||
|
"aiSuggestions.title": "AI Image Analysis",
|
||||||
|
"aiSuggestions.close": "Close",
|
||||||
|
"aiSuggestions.analyzing": "Analyzing image...",
|
||||||
|
"aiSuggestions.titleField": "Title",
|
||||||
|
"aiSuggestions.altField": "Alt Text",
|
||||||
|
"aiSuggestions.captionField": "Caption",
|
||||||
|
"aiSuggestions.hasExisting": "(has existing value)",
|
||||||
|
"aiSuggestions.current": "Current",
|
||||||
|
"aiSuggestions.intro": "Select which AI-generated values to apply. Existing values are preserved by default.",
|
||||||
|
"aiSuggestions.empty": "No suggestions were generated for this image.",
|
||||||
|
"aiSuggestions.wait": "Please wait...",
|
||||||
|
"aiSuggestions.applySelected": "Apply Selected",
|
||||||
|
"insert.title.link": "Insert Link",
|
||||||
|
"insert.title.image": "Insert Image",
|
||||||
|
"insert.tab.linkInternal": "Link to Post",
|
||||||
|
"insert.tab.imageInternal": "Media Library",
|
||||||
|
"insert.tab.linkExternal": "External URL",
|
||||||
|
"insert.tab.imageExternal": "External Image",
|
||||||
|
"insert.searchPlaceholder.link": "Search posts by title or content...",
|
||||||
|
"insert.searchPlaceholder.image": "Search media by name, title, or alt text...",
|
||||||
|
"insert.status.searching": "Searching...",
|
||||||
|
"insert.status.typeMore": "Type at least 2 characters to search",
|
||||||
|
"insert.status.noResults": "No {kind} found for \"{query}\"",
|
||||||
|
"insert.label.url": "URL",
|
||||||
|
"insert.label.linkTextOptional": "Link Text (optional)",
|
||||||
|
"insert.label.altText": "Alt Text",
|
||||||
|
"insert.placeholder.linkUrl": "https://example.com",
|
||||||
|
"insert.placeholder.imageUrl": "https://example.com/image.jpg",
|
||||||
|
"insert.placeholder.linkText": "Click here",
|
||||||
|
"insert.placeholder.imageAlt": "Description of the image",
|
||||||
|
"insert.submit.link": "Insert Link",
|
||||||
|
"insert.submit.image": "Insert Image",
|
||||||
|
"insert.hint.internal": "Use ↑↓ to navigate, Enter to select, Esc to close",
|
||||||
|
"insert.hint.external": "Enter URL and press Enter or click button, Esc to close",
|
||||||
|
"insert.hint.canonicalPost": "Canonical: /YYYY/MM/DD/slug",
|
||||||
|
"insert.hint.canonicalMedia": "Canonical: /media/YYYY/MM/file.ext",
|
||||||
|
"postLinks.loading": "Loading links...",
|
||||||
|
"postLinks.link": "link",
|
||||||
|
"postLinks.links": "links",
|
||||||
|
"postLinks.linksTo": "Links to ({count})",
|
||||||
|
"postLinks.linkedBy": "Linked by ({count})",
|
||||||
|
"postLinks.openTitle": "Open: {title}",
|
||||||
|
"docs.title": "Documentation",
|
||||||
|
"docs.subtitle": "User guide for this installed bDS version.",
|
||||||
|
"gitDiff.header": "Diff: {target}",
|
||||||
|
"gitDiff.noProject": "No active project selected.",
|
||||||
|
"gitDiff.noProjectPath": "Unable to resolve project path.",
|
||||||
|
"gitDiff.loadFailed": "Failed to load diff.",
|
||||||
|
"gitDiff.loading": "Loading diff...",
|
||||||
|
"gitDiff.changedFiles": "Changed files",
|
||||||
|
"gitDiff.previousFile": "Previous file",
|
||||||
|
"gitDiff.nextFile": "Next file",
|
||||||
|
"errorModal.error": "Error",
|
||||||
|
"errorModal.stackTrace": "Stack Trace",
|
||||||
|
"errorModal.copyClipboard": "Copy to clipboard",
|
||||||
|
"errorModal.copy": "Copy",
|
||||||
|
"errorModal.noStack": "No stack trace available",
|
||||||
|
"confirmDelete.title": "Confirm Deletion",
|
||||||
|
"confirmDelete.promptPost": "Are you sure you want to delete the post",
|
||||||
|
"confirmDelete.promptMedia": "Are you sure you want to delete the media file",
|
||||||
|
"confirmDelete.warning": "Warning:",
|
||||||
|
"confirmDelete.referencedBy": "This {itemType} is referenced by the following items:",
|
||||||
|
"confirmDelete.note": "Deleting this {itemType} will remove all these references.",
|
||||||
|
"confirmDelete.cancel": "Cancel",
|
||||||
|
"confirmDelete.deletePost": "Delete Post",
|
||||||
|
"confirmDelete.deleteMedia": "Delete Media",
|
||||||
|
"confirmDelete.itemType.post": "post",
|
||||||
|
"confirmDelete.itemType.media": "media",
|
||||||
|
"lightbox.close": "Close (Esc)",
|
||||||
|
"lightbox.previous": "Previous (←)",
|
||||||
|
"lightbox.next": "Next (→)",
|
||||||
|
"credentials.error.load": "Failed to load credentials:",
|
||||||
|
"credentials.error.save": "Failed to save credentials:",
|
||||||
|
"credentials.toast.saved": "Credentials saved",
|
||||||
|
"credentials.toast.saveFailed": "Failed to save credentials",
|
||||||
|
"credentials.toast.testing": "Testing {type} connection...",
|
||||||
|
"credentials.toast.connectionFailed": "Connection failed - check credentials",
|
||||||
|
"credentials.tab.ftp": "FTP",
|
||||||
|
"credentials.tab.ssh": "SSH",
|
||||||
|
"credentials.ftp.title": "FTP Publishing",
|
||||||
|
"credentials.ftp.description": "Configure FTP for publishing your blog to a web server.",
|
||||||
|
"credentials.ssh.title": "SSH Publishing",
|
||||||
|
"credentials.ssh.description": "Configure SSH for secure publishing to your server.",
|
||||||
|
"credentials.field.host": "Host",
|
||||||
|
"credentials.field.username": "Username",
|
||||||
|
"credentials.field.password": "Password",
|
||||||
|
"credentials.field.sshKeyPath": "SSH Key Path",
|
||||||
|
"credentials.action.testConnection": "Test Connection",
|
||||||
|
"credentials.ftp.placeholder.host": "ftp.example.com",
|
||||||
|
"credentials.ftp.placeholder.username": "ftp-user",
|
||||||
|
"credentials.ftp.placeholder.password": "Password",
|
||||||
|
"credentials.ssh.placeholder.host": "server.example.com",
|
||||||
|
"credentials.ssh.placeholder.username": "ssh-user",
|
||||||
|
"credentials.ssh.placeholder.keyPath": "~/.ssh/id_rsa",
|
||||||
|
"gitSidebar.header": "SOURCE CONTROL",
|
||||||
|
"gitSidebar.loading": "Loading...",
|
||||||
|
"gitSidebar.error.fetchRemoteUpdates": "Failed to fetch remote updates.",
|
||||||
|
"gitSidebar.error.refreshRemoteState": "Unable to refresh remote tracking state.",
|
||||||
|
"gitSidebar.error.gitMissing": "Git executable not found. Please install Git and restart the app.",
|
||||||
|
"gitSidebar.error.noActiveProject": "No active project selected.",
|
||||||
|
"gitSidebar.error.loadRepoStatus": "Unable to load repository status.",
|
||||||
|
"gitSidebar.error.initFailed": "Failed to initialize git repository.",
|
||||||
|
"gitSidebar.error.actionFailed": "Failed to {action}.",
|
||||||
|
"gitSidebar.error.commitFailed": "Failed to commit changes.",
|
||||||
|
"gitSidebar.progress.preparingInit": "Preparing repository initialization...",
|
||||||
|
"gitSidebar.progress.pushingRemote": "Pushing commits to remote... this can take a while for large uploads.",
|
||||||
|
"gitSidebar.progress.fetching": "Fetching remote updates...",
|
||||||
|
"gitSidebar.progress.pulling": "Pulling latest changes...",
|
||||||
|
"gitSidebar.progress.pruningLfs": "Pruning local Git LFS cache...",
|
||||||
|
"gitSidebar.progress.committing": "Creating commit...",
|
||||||
|
"gitSidebar.progress.initializingRepo": "Initializing repository...",
|
||||||
|
"gitSidebar.history.synced": "Synced",
|
||||||
|
"gitSidebar.history.localOnly": "Local only",
|
||||||
|
"gitSidebar.history.remoteOnly": "Remote only",
|
||||||
|
"gitSidebar.init.transcript": "Initialization transcript",
|
||||||
|
"gitSidebar.aria.repoActions": "Repository actions",
|
||||||
|
"gitSidebar.aria.openChanges": "Open Changes",
|
||||||
|
"gitSidebar.aria.commitStatusLegend": "Commit status legend",
|
||||||
|
"gitSidebar.aria.versionHistory": "Version History",
|
||||||
|
"gitSidebar.action.fetch": "Fetch",
|
||||||
|
"gitSidebar.action.fetching": "Fetching...",
|
||||||
|
"gitSidebar.action.pull": "Pull",
|
||||||
|
"gitSidebar.action.pulling": "Pulling...",
|
||||||
|
"gitSidebar.action.push": "Push",
|
||||||
|
"gitSidebar.action.pushing": "Pushing...",
|
||||||
|
"gitSidebar.action.pruneLfs": "Prune LFS",
|
||||||
|
"gitSidebar.action.pruning": "Pruning...",
|
||||||
|
"gitSidebar.action.commit": "Commit",
|
||||||
|
"gitSidebar.action.committing": "Committing...",
|
||||||
|
"gitSidebar.action.initializeGit": "Initialize Git",
|
||||||
|
"gitSidebar.action.initializing": "Initializing...",
|
||||||
|
"gitSidebar.openChanges": "Open Changes ({count})",
|
||||||
|
"gitSidebar.versionHistory": "Version History ({count})",
|
||||||
|
"gitSidebar.loadingChanges": "Loading changes...",
|
||||||
|
"gitSidebar.noChanges": "No changes",
|
||||||
|
"gitSidebar.loadingHistory": "Loading history...",
|
||||||
|
"gitSidebar.noCommits": "No commits yet",
|
||||||
|
"gitSidebar.branch": "Branch: {branch}",
|
||||||
|
"gitSidebar.aheadBehind": "ahead {ahead} / behind {behind}",
|
||||||
|
"gitSidebar.notRepo": "This project is not a git repository.",
|
||||||
|
"gitSidebar.placeholder.remoteUrl": "Optional remote repository URL",
|
||||||
|
"gitSidebar.placeholder.commitMessage": "Commit message",
|
||||||
|
"editor.untitled": "Untitled",
|
||||||
|
"tabBar.style": "Style",
|
||||||
|
"tabBar.loading": "Loading...",
|
||||||
|
"tabBar.unknown": "Unknown",
|
||||||
|
"tabBar.preview": "Preview",
|
||||||
|
"tabBar.modified": "Modified",
|
||||||
|
"tabBar.closeHint": "Close (Ctrl+W)",
|
||||||
|
"tabBar.scrollLeft": "Scroll tabs left",
|
||||||
|
"tabBar.scrollRight": "Scroll tabs right",
|
||||||
|
"tabBar.commitTitle": "Commit {hash}",
|
||||||
|
"tabBar.error.fetchPostTitle": "Failed to fetch post title:",
|
||||||
|
"tabBar.error.fetchChatTitle": "Failed to fetch chat title:",
|
||||||
|
"tabBar.error.fetchImportTitle": "Failed to fetch import definition title:",
|
||||||
|
"tabBar.error.fetchCommitTitle": "Failed to fetch commit titles:",
|
||||||
|
"metadataDiff.title": "Metadata Diff Tool",
|
||||||
|
"metadataDiff.description": "Compare post metadata between database and markdown files. Fix inconsistencies caused by bugs or manual edits.",
|
||||||
|
"metadataDiff.error.loadStats": "Failed to load database statistics",
|
||||||
|
"metadataDiff.error.scan": "Failed to scan for differences",
|
||||||
|
"metadataDiff.progress.starting": "Starting scan...",
|
||||||
|
"metadataDiff.progress.scanningPublished": "Scanning published posts...",
|
||||||
|
"metadataDiff.progress.scanning": "Scanning...",
|
||||||
|
"metadataDiff.action.scan": "Scan for Differences",
|
||||||
|
"metadataDiff.action.rescan": "Re-scan",
|
||||||
|
"metadataDiff.stats.totalPosts": "Total Posts",
|
||||||
|
"metadataDiff.stats.published": "Published",
|
||||||
|
"metadataDiff.stats.drafts": "Drafts",
|
||||||
|
"metadataDiff.stats.mediaFiles": "Media Files",
|
||||||
|
"metadataDiff.summary.noDiffs": "✅ No differences found! All {total} published posts are in sync.",
|
||||||
|
"metadataDiff.summary.withDiffs": "⚠️ Found {count} posts with differences out of {total} published posts.",
|
||||||
|
"metadataDiff.group.differences": "{label} Differences",
|
||||||
|
"metadataDiff.group.postsCount": "{count} posts",
|
||||||
|
"metadataDiff.sync.failed": "failed",
|
||||||
|
"metadataDiff.sync.dbToFile.title": "Update files with database values",
|
||||||
|
"metadataDiff.sync.dbToFile.success": "Synced {success} posts to files{failed}",
|
||||||
|
"metadataDiff.sync.dbToFile.error": "Failed to sync to files",
|
||||||
|
"metadataDiff.sync.fileToDb.title": "Update database with file values",
|
||||||
|
"metadataDiff.sync.fileToDb.success": "Synced {success} files to database{failed}",
|
||||||
|
"metadataDiff.sync.fileToDb.error": "Failed to sync to database",
|
||||||
|
"metadataDiff.value.database": "Database",
|
||||||
|
"metadataDiff.value.file": "File",
|
||||||
|
"metadataDiff.empty": "Click \"Scan for Differences\" to compare database metadata with file metadata."
|
||||||
|
}
|
||||||
320
src/renderer/i18n/locales/es.json
Normal file
320
src/renderer/i18n/locales/es.json
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
{
|
||||||
|
"common.save": "Guardar",
|
||||||
|
"common.cancel": "Cancelar",
|
||||||
|
"common.clear": "Limpiar",
|
||||||
|
"common.settings": "Configuración",
|
||||||
|
"common.tasks": "Tareas",
|
||||||
|
"common.running": "en ejecución",
|
||||||
|
"common.pending": "pendiente",
|
||||||
|
"activity.posts": "Entradas",
|
||||||
|
"activity.pages": "Páginas",
|
||||||
|
"activity.media": "Medios",
|
||||||
|
"activity.tags": "Etiquetas",
|
||||||
|
"activity.aiAssistant": "Asistente IA",
|
||||||
|
"activity.import": "Importar",
|
||||||
|
"activity.sourceControl": "Control de código fuente",
|
||||||
|
"activity.toggleHint": "(haz clic de nuevo para alternar la barra lateral)",
|
||||||
|
"tasks.backgroundTasks": "Tareas en segundo plano",
|
||||||
|
"tasks.clearCompleted": "Limpiar completadas",
|
||||||
|
"tasks.recent": "Recientes",
|
||||||
|
"tasks.noActive": "No hay tareas activas",
|
||||||
|
"tasks.cancelTask": "Cancelar tarea",
|
||||||
|
"tasks.triggerTitle": "{running} en ejecución, {pending} pendiente",
|
||||||
|
"app.taskCompleted": "Tarea completada: {message}",
|
||||||
|
"app.taskFailed": "Tarea fallida: {message}",
|
||||||
|
"app.databaseRebuildFailed": "La reconstrucción de la base de datos falló",
|
||||||
|
"app.textReindexFailed": "La reindexación de texto falló",
|
||||||
|
"app.sitemapGenerationFailed": "La generación del sitemap falló",
|
||||||
|
"app.previewOpenFailed": "No se pudo abrir la vista previa de la entrada seleccionada",
|
||||||
|
"app.metadataDiff": "Diferencia de Metadatos",
|
||||||
|
"app.importComplete": "Importación completada: {posts} entradas, {media} archivos multimedia",
|
||||||
|
"settings.language.english": "Inglés",
|
||||||
|
"settings.language.german": "Alemán",
|
||||||
|
"settings.language.french": "Francés",
|
||||||
|
"settings.language.italian": "Italiano",
|
||||||
|
"settings.language.spanish": "Español",
|
||||||
|
"settings.language.portuguese": "Portugués (Português)",
|
||||||
|
"settings.language.dutch": "Neerlandés (Nederlands)",
|
||||||
|
"settings.language.polish": "Polaco (Polski)",
|
||||||
|
"settings.language.russian": "Ruso (Русский)",
|
||||||
|
"settings.language.japanese": "Japonés (日本語)",
|
||||||
|
"settings.language.chinese": "Chino (中文)",
|
||||||
|
"settings.language.korean": "Coreano (한국어)",
|
||||||
|
"settings.language.arabic": "Árabe (العربية)",
|
||||||
|
"settings.language.hindi": "Hindi",
|
||||||
|
"settings.language.turkish": "Turco (Türkçe)",
|
||||||
|
"settings.language.swedish": "Sueco (Svenska)",
|
||||||
|
"settings.language.danish": "Danés (Dansk)",
|
||||||
|
"settings.language.norwegian": "Noruego (Norsk)",
|
||||||
|
"settings.language.finnish": "Finés (Suomi)",
|
||||||
|
"settings.language.czech": "Checo (Čeština)",
|
||||||
|
"settings.project.title": "Proyecto",
|
||||||
|
"settings.project.browse": "Examinar",
|
||||||
|
"settings.project.reset": "Restablecer",
|
||||||
|
"settings.project.resetDefault": "Restablecer por defecto",
|
||||||
|
"settings.project.selectDataFolder": "Seleccionar carpeta de datos del proyecto",
|
||||||
|
"settings.editor.title": "Editor de texto",
|
||||||
|
"settings.editor.mode.wysiwyg": "WYSIWYG (editor visual)",
|
||||||
|
"settings.editor.mode.markdown": "Markdown (fuente)",
|
||||||
|
"settings.editor.mode.preview": "Vista previa (solo lectura)",
|
||||||
|
"settings.editor.diff.inline": "En línea",
|
||||||
|
"settings.editor.diff.sideBySide": "Lado a lado",
|
||||||
|
"settings.content.title": "Categorías de entradas",
|
||||||
|
"settings.content.renderInLists": "Mostrar en listas",
|
||||||
|
"settings.content.showTitles": "Mostrar títulos",
|
||||||
|
"settings.ai.title": "Asistente IA",
|
||||||
|
"settings.ai.noModels": "No hay modelos disponibles",
|
||||||
|
"settings.publishing.ftpTitle": "Publicación FTP",
|
||||||
|
"settings.publishing.sshTitle": "Publicación SSH",
|
||||||
|
"settings.data.title": "Mantenimiento de base de datos",
|
||||||
|
"settings.data.fileSystemTitle": "Sistema de archivos",
|
||||||
|
"settings.search.placeholder": "Buscar configuración...",
|
||||||
|
"settings.search.noResults": "No configuración found matching \"{query}\"",
|
||||||
|
"settings.search.clear": "Limpiar búsqueda",
|
||||||
|
"settings.toast.publishingSaved": "Credenciales de publicación guardadas",
|
||||||
|
"settings.toast.saveCredentialsFailed": "No se pudo save credentials",
|
||||||
|
"settings.toast.credentialsCleared": "Credenciales de {type} borradas",
|
||||||
|
"settings.toast.projectSaved": "Project configuración saved",
|
||||||
|
"settings.toast.projectSaveFailed": "No se pudo save project configuración",
|
||||||
|
"settings.toast.categoryAdded": "Categoría \"{category}\" agregada",
|
||||||
|
"settings.toast.categoryAddFailed": "No se pudo add category",
|
||||||
|
"settings.toast.categoryExists": "La categoría ya existe",
|
||||||
|
"settings.toast.categoryProtected": "No se puede eliminar la categoría estándar \"{category}\"",
|
||||||
|
"settings.toast.categoryAtLeastOne": "Debe haber al menos una categoría",
|
||||||
|
"settings.toast.categoryRemoved": "Categoría \"{category}\" eliminada",
|
||||||
|
"settings.toast.categoryRemoveFailed": "No se pudo remove category",
|
||||||
|
"settings.toast.categoriesReset": "Categorías restablecidas a los valores predeterminados",
|
||||||
|
"settings.toast.categoriesResetFailed": "No se pudo reset categories",
|
||||||
|
"settings.toast.categorySettingsUpdateFailed": "No se pudo update category configuración",
|
||||||
|
"settings.toast.systemPromptSaved": "Prompt del sistema guardado",
|
||||||
|
"settings.toast.systemPromptSaveFailed": "No se pudo save system prompt",
|
||||||
|
"settings.toast.systemPromptReset": "Prompt del sistema restablecido al predeterminado",
|
||||||
|
"settings.toast.systemPromptResetFailed": "No se pudo reset system prompt",
|
||||||
|
"settings.toast.apiKeySaved": "Clave API guardada y validada",
|
||||||
|
"settings.toast.apiKeyInvalid": "Clave API no válida",
|
||||||
|
"settings.toast.apiKeySaveFailed": "No se pudo save API key",
|
||||||
|
"settings.toast.defaultModelUpdated": "Modelo predeterminado actualizado",
|
||||||
|
"settings.toast.defaultModelUpdateFailed": "No se pudo set default model",
|
||||||
|
"settings.toast.rebuildPostsLoading": "Rebuilding entradas database...",
|
||||||
|
"settings.toast.rebuildPostsSuccess": "Base de datos de publicaciones reconstruida",
|
||||||
|
"settings.toast.rebuildPostsFailed": "No se pudo rebuild entradas database",
|
||||||
|
"settings.toast.rebuildMediaLoading": "Rebuilding medios database...",
|
||||||
|
"settings.toast.rebuildMediaSuccess": "Base de datos de medios reconstruida",
|
||||||
|
"settings.toast.rebuildMediaFailed": "No se pudo rebuild medios database",
|
||||||
|
"settings.toast.rebuildLinksLoading": "Rebuilding entrada links...",
|
||||||
|
"settings.toast.rebuildLinksSuccess": "Enlaces de publicaciones reconstruidos",
|
||||||
|
"settings.toast.rebuildLinksFailed": "No se pudo rebuild entrada links",
|
||||||
|
"settings.toast.thumbnailsLoading": "Generando miniaturas...",
|
||||||
|
"settings.toast.thumbnailsGenerated": "Se generaron {count} miniaturas",
|
||||||
|
"settings.toast.thumbnailsAlreadyExist": "Todas las miniaturas ya existen",
|
||||||
|
"settings.toast.thumbnailsComplete": "Generación de miniaturas completa",
|
||||||
|
"settings.toast.thumbnailsFailed": "No se pudo generate thumbnails",
|
||||||
|
"chat.setupTitle": "Configuración de chat IA",
|
||||||
|
"chat.apiKeyRequiredTitle": "Se requiere clave API de OpenCode Zen",
|
||||||
|
"chat.apiKeyRequiredDescription": "Introduce tu clave API de OpenCode para habilitar el chat de IA.",
|
||||||
|
"chat.apiKeyPlaceholder": "Introduce tu clave API...",
|
||||||
|
"chat.apiKeySave": "Guardar clave",
|
||||||
|
"chat.apiKeyValidating": "Validando...",
|
||||||
|
"chat.apiKeyInvalid": "Clave API no válida. Compruébala e inténtalo de nuevo.",
|
||||||
|
"chat.apiKeyValidationFailed": "No se pudo validate API key.",
|
||||||
|
"chat.newChat": "Nuevo chat",
|
||||||
|
"chat.welcomeTitle": "Bienvenido al asistente de IA",
|
||||||
|
"chat.welcomeDescription": "I can help you manage your entradas and medios. Try asking me to:",
|
||||||
|
"chat.welcomeTipSearch": "Buscar for entradas about a specific topic",
|
||||||
|
"chat.welcomeTipDetails": "Get details about a specific entrada",
|
||||||
|
"chat.welcomeTipTags": "Lista todas las etiquetas o categorías de tu blog",
|
||||||
|
"chat.welcomeTipMetadata": "Update metadata for entradas or medios",
|
||||||
|
"chat.welcomeTipImages": "List all images in your medios library",
|
||||||
|
"chat.role.you": "Tú",
|
||||||
|
"chat.role.assistant": "Asistente",
|
||||||
|
"chat.stop": "Detener",
|
||||||
|
"chat.inputPlaceholder": "Escribe un mensaje...",
|
||||||
|
"chat.errorPrefix": "Error del sistema: {error}",
|
||||||
|
"chat.errorNoResponse": "No se pudo get a response. Please try again.",
|
||||||
|
"chat.errorEmptyResponse": "El modelo devolvió una respuesta vacía. Prueba otro modelo o reformula tu pregunta.",
|
||||||
|
"chat.errorGeneric": "Sorry, an error occurred while processing your mensaje.",
|
||||||
|
"chat.cancelledSuffix": "(cancelado)",
|
||||||
|
"aiSuggestions.title": "Análisis de imagen IA",
|
||||||
|
"aiSuggestions.close": "Cerrar",
|
||||||
|
"aiSuggestions.analyzing": "Analizando imagen...",
|
||||||
|
"aiSuggestions.titleField": "Título",
|
||||||
|
"aiSuggestions.altField": "Texto alternativo",
|
||||||
|
"aiSuggestions.captionField": "Pie de foto",
|
||||||
|
"aiSuggestions.hasExisting": "(tiene valor existente)",
|
||||||
|
"aiSuggestions.current": "Actual",
|
||||||
|
"aiSuggestions.intro": "Selecciona qué valores generados por IA aplicar. Los valores existentes se conservan de forma predeterminada.",
|
||||||
|
"aiSuggestions.empty": "No se generaron sugerencias para esta imagen.",
|
||||||
|
"aiSuggestions.wait": "Por favor espera...",
|
||||||
|
"aiSuggestions.applySelected": "Aplicar seleccionados",
|
||||||
|
"insert.title.link": "Insertar enlace",
|
||||||
|
"insert.title.image": "Insertar imagen",
|
||||||
|
"insert.tab.linkInternal": "Enlazar a entrada",
|
||||||
|
"insert.tab.imageInternal": "Biblioteca multimedia",
|
||||||
|
"insert.tab.linkExternal": "URL externa",
|
||||||
|
"insert.tab.imageExternal": "Imagen externa",
|
||||||
|
"insert.searchPlaceholder.link": "Buscar entradas by title or content...",
|
||||||
|
"insert.searchPlaceholder.image": "Buscar medios by name, title, or alt text...",
|
||||||
|
"insert.status.searching": "Buscando...",
|
||||||
|
"insert.status.typeMore": "Escribe al menos 2 caracteres para buscar",
|
||||||
|
"insert.status.noResults": "No se encontró {kind} para \"{query}\"",
|
||||||
|
"insert.label.url": "Dirección URL",
|
||||||
|
"insert.label.linkTextOptional": "Texto del enlace (opcional)",
|
||||||
|
"insert.label.altText": "Texto alternativo",
|
||||||
|
"insert.placeholder.linkUrl": "https://ejemplo.es",
|
||||||
|
"insert.placeholder.imageUrl": "https://ejemplo.es/imagen.jpg",
|
||||||
|
"insert.placeholder.linkText": "Haz clic aquí",
|
||||||
|
"insert.placeholder.imageAlt": "Descripción de la imagen",
|
||||||
|
"insert.submit.link": "Insertar enlace",
|
||||||
|
"insert.submit.image": "Insertar imagen",
|
||||||
|
"insert.hint.internal": "Usa ↑↓ para navegar, Enter para seleccionar, Esc para cerrar",
|
||||||
|
"insert.hint.external": "Introduce la URL y pulsa Enter o haz clic en el botón, Esc para cerrar",
|
||||||
|
"insert.hint.canonicalPost": "Canónico: /YYYY/MM/DD/slug",
|
||||||
|
"insert.hint.canonicalMedia": "Canonical: /medios/YYYY/MM/file.ext",
|
||||||
|
"postLinks.loading": "Cargando enlaces...",
|
||||||
|
"postLinks.link": "enlace",
|
||||||
|
"postLinks.links": "enlaces",
|
||||||
|
"postLinks.linksTo": "Enlaces a ({count})",
|
||||||
|
"postLinks.linkedBy": "Enlazado por ({count})",
|
||||||
|
"postLinks.openTitle": "Abrir: {title}",
|
||||||
|
"docs.title": "Documentación",
|
||||||
|
"docs.subtitle": "Guía de usuario para esta versión instalada de bDS.",
|
||||||
|
"gitDiff.header": "Diferencia: {target}",
|
||||||
|
"gitDiff.noProject": "No hay un proyecto activo seleccionado.",
|
||||||
|
"gitDiff.noProjectPath": "No se pudo resolver la ruta del proyecto.",
|
||||||
|
"gitDiff.loadFailed": "No se pudo load diff.",
|
||||||
|
"gitDiff.loading": "Cargando diff...",
|
||||||
|
"gitDiff.changedFiles": "Archivos modificados",
|
||||||
|
"gitDiff.previousFile": "Archivo anterior",
|
||||||
|
"gitDiff.nextFile": "Archivo siguiente",
|
||||||
|
"errorModal.error": "Error del sistema",
|
||||||
|
"errorModal.stackTrace": "Traza de pila",
|
||||||
|
"errorModal.copyClipboard": "Copiar al portapapeles",
|
||||||
|
"errorModal.copy": "Copiar",
|
||||||
|
"errorModal.noStack": "No hay traza de pila disponible",
|
||||||
|
"confirmDelete.title": "Confirmar eliminación",
|
||||||
|
"confirmDelete.promptPost": "Are you sure you want to delete the entrada",
|
||||||
|
"confirmDelete.promptMedia": "Are you sure you want to delete the medios file",
|
||||||
|
"confirmDelete.warning": "Advertencia:",
|
||||||
|
"confirmDelete.referencedBy": "Este {itemType} está referenciado por los siguientes elementos:",
|
||||||
|
"confirmDelete.note": "Eliminar este {itemType} quitará todas estas referencias.",
|
||||||
|
"confirmDelete.cancel": "Cancelar",
|
||||||
|
"confirmDelete.deletePost": "Eliminar publicación",
|
||||||
|
"confirmDelete.deleteMedia": "Eliminar medio",
|
||||||
|
"confirmDelete.itemType.post": "entrada",
|
||||||
|
"confirmDelete.itemType.media": "medios",
|
||||||
|
"lightbox.close": "Cerrar (Esc)",
|
||||||
|
"lightbox.previous": "Anterior (←)",
|
||||||
|
"lightbox.next": "Siguiente (→)",
|
||||||
|
"credentials.error.load": "No se pudo load credentials:",
|
||||||
|
"credentials.error.save": "No se pudo save credentials:",
|
||||||
|
"credentials.toast.saved": "Credenciales guardadas",
|
||||||
|
"credentials.toast.saveFailed": "No se pudo save credentials",
|
||||||
|
"credentials.toast.testing": "Probando conexión {type}...",
|
||||||
|
"credentials.toast.connectionFailed": "Connection falló - check credentials",
|
||||||
|
"credentials.tab.ftp": "Acceso FTP",
|
||||||
|
"credentials.tab.ssh": "Acceso SSH",
|
||||||
|
"credentials.ftp.title": "Publicación FTP",
|
||||||
|
"credentials.ftp.description": "Configura FTP para publicar tu blog en un servidor web.",
|
||||||
|
"credentials.ssh.title": "Publicación SSH",
|
||||||
|
"credentials.ssh.description": "Configura SSH para publicar de forma segura en tu servidor.",
|
||||||
|
"credentials.field.host": "Servidor",
|
||||||
|
"credentials.field.username": "Nombre de usuario",
|
||||||
|
"credentials.field.password": "Contraseña",
|
||||||
|
"credentials.field.sshKeyPath": "Ruta de clave SSH",
|
||||||
|
"credentials.action.testConnection": "Probar conexión",
|
||||||
|
"credentials.ftp.placeholder.host": "ftp.ejemplo.es",
|
||||||
|
"credentials.ftp.placeholder.username": "usuario-ftp",
|
||||||
|
"credentials.ftp.placeholder.password": "Contraseña",
|
||||||
|
"credentials.ssh.placeholder.host": "servidor.ejemplo.es",
|
||||||
|
"credentials.ssh.placeholder.username": "usuario-ssh",
|
||||||
|
"credentials.ssh.placeholder.keyPath": "~/.ssh/clave_id_rsa",
|
||||||
|
"gitSidebar.header": "CONTROL DE CÓDIGO FUENTE",
|
||||||
|
"gitSidebar.loading": "Cargando...",
|
||||||
|
"gitSidebar.error.fetchRemoteUpdates": "No se pudo fetch remote updates.",
|
||||||
|
"gitSidebar.error.refreshRemoteState": "No se pudo actualizar el estado de seguimiento remoto.",
|
||||||
|
"gitSidebar.error.gitMissing": "No se encontró el ejecutable de Git. Instala Git y reinicia la aplicación.",
|
||||||
|
"gitSidebar.error.noActiveProject": "No hay un proyecto activo seleccionado.",
|
||||||
|
"gitSidebar.error.loadRepoStatus": "No se pudo cargar el estado del repositorio.",
|
||||||
|
"gitSidebar.error.initFailed": "No se pudo initialize git repository.",
|
||||||
|
"gitSidebar.error.actionFailed": "No se pudo {action}.",
|
||||||
|
"gitSidebar.error.commitFailed": "No se pudo commit changes.",
|
||||||
|
"gitSidebar.progress.preparingInit": "Preparando inicialización del repositorio...",
|
||||||
|
"gitSidebar.progress.pushingRemote": "Enviando commits al remoto... esto puede tardar con cargas grandes.",
|
||||||
|
"gitSidebar.progress.fetching": "Obteniendo actualizaciones remotas...",
|
||||||
|
"gitSidebar.progress.pulling": "Extrayendo los últimos cambios...",
|
||||||
|
"gitSidebar.progress.pruningLfs": "Limpiando caché local de Git LFS...",
|
||||||
|
"gitSidebar.progress.committing": "Creando commit...",
|
||||||
|
"gitSidebar.progress.initializingRepo": "Inicializando repositorio...",
|
||||||
|
"gitSidebar.history.synced": "Sincronizado",
|
||||||
|
"gitSidebar.history.localOnly": "Solo local",
|
||||||
|
"gitSidebar.history.remoteOnly": "Solo remoto",
|
||||||
|
"gitSidebar.init.transcript": "Registro de inicialización",
|
||||||
|
"gitSidebar.aria.repoActions": "Acciones del repositorio",
|
||||||
|
"gitSidebar.aria.openChanges": "Cambios abiertos",
|
||||||
|
"gitSidebar.aria.commitStatusLegend": "Leyenda del estado de commit",
|
||||||
|
"gitSidebar.aria.versionHistory": "Historial de versiones",
|
||||||
|
"gitSidebar.action.fetch": "Obtener",
|
||||||
|
"gitSidebar.action.fetching": "Obteniendo...",
|
||||||
|
"gitSidebar.action.pull": "Traer",
|
||||||
|
"gitSidebar.action.pulling": "Trayendo cambios...",
|
||||||
|
"gitSidebar.action.push": "Enviar",
|
||||||
|
"gitSidebar.action.pushing": "Enviando...",
|
||||||
|
"gitSidebar.action.pruneLfs": "Podar LFS",
|
||||||
|
"gitSidebar.action.pruning": "Podando...",
|
||||||
|
"gitSidebar.action.commit": "Realizar commit",
|
||||||
|
"gitSidebar.action.committing": "Haciendo commit...",
|
||||||
|
"gitSidebar.action.initializeGit": "Inicializar Git",
|
||||||
|
"gitSidebar.action.initializing": "Inicializando...",
|
||||||
|
"gitSidebar.openChanges": "Abrir Changes ({count})",
|
||||||
|
"gitSidebar.versionHistory": "Historial de versiones ({count})",
|
||||||
|
"gitSidebar.loadingChanges": "Cargando cambios...",
|
||||||
|
"gitSidebar.noChanges": "Sin cambios",
|
||||||
|
"gitSidebar.loadingHistory": "Cargando historial...",
|
||||||
|
"gitSidebar.noCommits": "Aún no hay commits",
|
||||||
|
"gitSidebar.branch": "Rama: {branch}",
|
||||||
|
"gitSidebar.aheadBehind": "adelante {ahead} / detrás {behind}",
|
||||||
|
"gitSidebar.notRepo": "Este proyecto no es un repositorio git.",
|
||||||
|
"gitSidebar.placeholder.remoteUrl": "URL opcional del repositorio remoto",
|
||||||
|
"gitSidebar.placeholder.commitMessage": "Mensaje de commit",
|
||||||
|
"editor.untitled": "Sin título",
|
||||||
|
"tabBar.style": "Estilo",
|
||||||
|
"tabBar.loading": "Cargando...",
|
||||||
|
"tabBar.unknown": "Desconocido",
|
||||||
|
"tabBar.preview": "Vista previa",
|
||||||
|
"tabBar.modified": "Modificado",
|
||||||
|
"tabBar.closeHint": "Cerrar (Ctrl+W)",
|
||||||
|
"tabBar.scrollLeft": "Desplazar pestañas a la izquierda",
|
||||||
|
"tabBar.scrollRight": "Desplazar pestañas a la derecha",
|
||||||
|
"tabBar.commitTitle": "Confirmación {hash}",
|
||||||
|
"tabBar.error.fetchPostTitle": "No se pudo fetch entrada title:",
|
||||||
|
"tabBar.error.fetchChatTitle": "No se pudo fetch chat title:",
|
||||||
|
"tabBar.error.fetchImportTitle": "No se pudo fetch import definition title:",
|
||||||
|
"tabBar.error.fetchCommitTitle": "No se pudo fetch commit titles:",
|
||||||
|
"metadataDiff.title": "Herramienta diff de metadatos",
|
||||||
|
"metadataDiff.description": "Compare entrada metadata between database and markdown files. Fix inconsistencies caused by bugs or manual edits.",
|
||||||
|
"metadataDiff.error.loadStats": "No se pudo load database statistics",
|
||||||
|
"metadataDiff.error.scan": "No se pudo scan for differences",
|
||||||
|
"metadataDiff.progress.starting": "Iniciando escaneo...",
|
||||||
|
"metadataDiff.progress.scanningPublished": "Scanning published entradas...",
|
||||||
|
"metadataDiff.progress.scanning": "Escaneando...",
|
||||||
|
"metadataDiff.action.scan": "Buscar diferencias",
|
||||||
|
"metadataDiff.action.rescan": "Volver a escanear",
|
||||||
|
"metadataDiff.stats.totalPosts": "Entradas totales",
|
||||||
|
"metadataDiff.stats.published": "Publicadas",
|
||||||
|
"metadataDiff.stats.drafts": "Borradores",
|
||||||
|
"metadataDiff.stats.mediaFiles": "Archivos multimedia",
|
||||||
|
"metadataDiff.summary.noDiffs": "✅ No differences found! All {total} published entradas are in sync.",
|
||||||
|
"metadataDiff.summary.withDiffs": "⚠️ Found {count} entradas with differences out of {total} published entradas.",
|
||||||
|
"metadataDiff.group.differences": "Diferencias de {label}",
|
||||||
|
"metadataDiff.group.postsCount": "{count} entradas",
|
||||||
|
"metadataDiff.sync.failed": "falló",
|
||||||
|
"metadataDiff.sync.dbToFile.title": "Actualizar archivos con valores de la base de datos",
|
||||||
|
"metadataDiff.sync.dbToFile.success": "Synced {success} entradas to files{falló}",
|
||||||
|
"metadataDiff.sync.dbToFile.error": "No se pudo sync to files",
|
||||||
|
"metadataDiff.sync.fileToDb.title": "Actualizar base de datos con valores de archivos",
|
||||||
|
"metadataDiff.sync.fileToDb.success": "Synced {success} files to database{falló}",
|
||||||
|
"metadataDiff.sync.fileToDb.error": "No se pudo sync to database",
|
||||||
|
"metadataDiff.value.database": "Base de datos",
|
||||||
|
"metadataDiff.value.file": "Archivo",
|
||||||
|
"metadataDiff.empty": "Haz clic en \"Buscar diferencias\" para comparar metadatos de base de datos con metadatos de archivos."
|
||||||
|
}
|
||||||
320
src/renderer/i18n/locales/fr.json
Normal file
320
src/renderer/i18n/locales/fr.json
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
{
|
||||||
|
"common.save": "Enregistrer",
|
||||||
|
"common.cancel": "Annuler",
|
||||||
|
"common.clear": "Effacer",
|
||||||
|
"common.settings": "Paramètres",
|
||||||
|
"common.tasks": "Tâches",
|
||||||
|
"common.running": "en cours",
|
||||||
|
"common.pending": "en attente",
|
||||||
|
"activity.posts": "Articles",
|
||||||
|
"activity.pages": "Pages du site",
|
||||||
|
"activity.media": "Médias",
|
||||||
|
"activity.tags": "Étiquettes",
|
||||||
|
"activity.aiAssistant": "Assistant IA",
|
||||||
|
"activity.import": "Importation",
|
||||||
|
"activity.sourceControl": "Contrôle de source",
|
||||||
|
"activity.toggleHint": "(cliquez à nouveau pour basculer la barre latérale)",
|
||||||
|
"tasks.backgroundTasks": "Tâches en arrière-plan",
|
||||||
|
"tasks.clearCompleted": "Effacer terminé",
|
||||||
|
"tasks.recent": "Récentes",
|
||||||
|
"tasks.noActive": "Aucune tâche active",
|
||||||
|
"tasks.cancelTask": "Annuler la tâche",
|
||||||
|
"tasks.triggerTitle": "{running} en cours, {pending} en attente",
|
||||||
|
"app.taskCompleted": "Tâche terminée : {message}",
|
||||||
|
"app.taskFailed": "Échec de la tâche : {message}",
|
||||||
|
"app.databaseRebuildFailed": "Échec de la reconstruction de la base de données",
|
||||||
|
"app.textReindexFailed": "Échec de la réindexation du texte",
|
||||||
|
"app.sitemapGenerationFailed": "Échec de la génération du sitemap",
|
||||||
|
"app.previewOpenFailed": "Impossible d’ouvrir l’aperçu de l’article sélectionné",
|
||||||
|
"app.metadataDiff": "Diff Métadonnées",
|
||||||
|
"app.importComplete": "Import terminé : {posts} articles, {media} fichiers média",
|
||||||
|
"settings.language.english": "Anglais",
|
||||||
|
"settings.language.german": "Allemand",
|
||||||
|
"settings.language.french": "Français",
|
||||||
|
"settings.language.italian": "Italien",
|
||||||
|
"settings.language.spanish": "Espagnol",
|
||||||
|
"settings.language.portuguese": "Portugais (Português)",
|
||||||
|
"settings.language.dutch": "Néerlandais (Nederlands)",
|
||||||
|
"settings.language.polish": "Polonais (Polski)",
|
||||||
|
"settings.language.russian": "Russe (Русский)",
|
||||||
|
"settings.language.japanese": "Japonais (日本語)",
|
||||||
|
"settings.language.chinese": "Chinois (中文)",
|
||||||
|
"settings.language.korean": "Coréen (한국어)",
|
||||||
|
"settings.language.arabic": "Arabe (العربية)",
|
||||||
|
"settings.language.hindi": "Hindi",
|
||||||
|
"settings.language.turkish": "Turc (Türkçe)",
|
||||||
|
"settings.language.swedish": "Suédois (Svenska)",
|
||||||
|
"settings.language.danish": "Danois (Dansk)",
|
||||||
|
"settings.language.norwegian": "Norvégien (Norsk)",
|
||||||
|
"settings.language.finnish": "Finnois (Suomi)",
|
||||||
|
"settings.language.czech": "Tchèque (Čeština)",
|
||||||
|
"settings.project.title": "Projet",
|
||||||
|
"settings.project.browse": "Parcourir",
|
||||||
|
"settings.project.reset": "Réinitialiser",
|
||||||
|
"settings.project.resetDefault": "Réinitialiser par défaut",
|
||||||
|
"settings.project.selectDataFolder": "Sélectionner le dossier de données du projet",
|
||||||
|
"settings.editor.title": "Éditeur",
|
||||||
|
"settings.editor.mode.wysiwyg": "WYSIWYG (éditeur visuel)",
|
||||||
|
"settings.editor.mode.markdown": "Markdown (source)",
|
||||||
|
"settings.editor.mode.preview": "Aperçu (lecture seule)",
|
||||||
|
"settings.editor.diff.inline": "En ligne",
|
||||||
|
"settings.editor.diff.sideBySide": "Côte à côte",
|
||||||
|
"settings.content.title": "Catégories d’articles",
|
||||||
|
"settings.content.renderInLists": "Afficher dans les listes",
|
||||||
|
"settings.content.showTitles": "Afficher les titres",
|
||||||
|
"settings.ai.title": "Assistant IA",
|
||||||
|
"settings.ai.noModels": "Aucun modèle disponible",
|
||||||
|
"settings.publishing.ftpTitle": "Publication FTP",
|
||||||
|
"settings.publishing.sshTitle": "Publication SSH",
|
||||||
|
"settings.data.title": "Maintenance de la base de données",
|
||||||
|
"settings.data.fileSystemTitle": "Système de fichiers",
|
||||||
|
"settings.search.placeholder": "Rechercher des paramètres...",
|
||||||
|
"settings.search.noResults": "No paramètres found matching \"{query}\"",
|
||||||
|
"settings.search.clear": "Effacer la recherche",
|
||||||
|
"settings.toast.publishingSaved": "Identifiants de publication enregistrés",
|
||||||
|
"settings.toast.saveCredentialsFailed": "Échec de save credentials",
|
||||||
|
"settings.toast.credentialsCleared": "Identifiants {type} effacés",
|
||||||
|
"settings.toast.projectSaved": "Project paramètres saved",
|
||||||
|
"settings.toast.projectSaveFailed": "Échec de save project paramètres",
|
||||||
|
"settings.toast.categoryAdded": "Catégorie \"{category}\" ajoutée",
|
||||||
|
"settings.toast.categoryAddFailed": "Échec de add category",
|
||||||
|
"settings.toast.categoryExists": "La catégorie existe déjà",
|
||||||
|
"settings.toast.categoryProtected": "Impossible de supprimer la catégorie standard \"{category}\"",
|
||||||
|
"settings.toast.categoryAtLeastOne": "Au moins une catégorie est requise",
|
||||||
|
"settings.toast.categoryRemoved": "Catégorie \"{category}\" supprimée",
|
||||||
|
"settings.toast.categoryRemoveFailed": "Échec de remove category",
|
||||||
|
"settings.toast.categoriesReset": "Catégories réinitialisées aux valeurs par défaut",
|
||||||
|
"settings.toast.categoriesResetFailed": "Échec de reset categories",
|
||||||
|
"settings.toast.categorySettingsUpdateFailed": "Échec de update category paramètres",
|
||||||
|
"settings.toast.systemPromptSaved": "Prompt système enregistré",
|
||||||
|
"settings.toast.systemPromptSaveFailed": "Échec de save system prompt",
|
||||||
|
"settings.toast.systemPromptReset": "Prompt système réinitialisé par défaut",
|
||||||
|
"settings.toast.systemPromptResetFailed": "Échec de reset system prompt",
|
||||||
|
"settings.toast.apiKeySaved": "Clé API enregistrée et validée",
|
||||||
|
"settings.toast.apiKeyInvalid": "Clé API invalide",
|
||||||
|
"settings.toast.apiKeySaveFailed": "Échec de save API key",
|
||||||
|
"settings.toast.defaultModelUpdated": "Modèle par défaut mis à jour",
|
||||||
|
"settings.toast.defaultModelUpdateFailed": "Échec de set default model",
|
||||||
|
"settings.toast.rebuildPostsLoading": "Rebuilding articles database...",
|
||||||
|
"settings.toast.rebuildPostsSuccess": "Base des articles reconstruite",
|
||||||
|
"settings.toast.rebuildPostsFailed": "Échec de rebuild articles database",
|
||||||
|
"settings.toast.rebuildMediaLoading": "Rebuilding médias database...",
|
||||||
|
"settings.toast.rebuildMediaSuccess": "Base médias reconstruite",
|
||||||
|
"settings.toast.rebuildMediaFailed": "Échec de rebuild médias database",
|
||||||
|
"settings.toast.rebuildLinksLoading": "Rebuilding article links...",
|
||||||
|
"settings.toast.rebuildLinksSuccess": "Liens d’articles reconstruits",
|
||||||
|
"settings.toast.rebuildLinksFailed": "Échec de rebuild article links",
|
||||||
|
"settings.toast.thumbnailsLoading": "Génération des miniatures...",
|
||||||
|
"settings.toast.thumbnailsGenerated": "{count} miniatures générées",
|
||||||
|
"settings.toast.thumbnailsAlreadyExist": "Toutes les miniatures existent déjà",
|
||||||
|
"settings.toast.thumbnailsComplete": "Génération des miniatures terminée",
|
||||||
|
"settings.toast.thumbnailsFailed": "Échec de generate thumbnails",
|
||||||
|
"chat.setupTitle": "Configuration du chat IA",
|
||||||
|
"chat.apiKeyRequiredTitle": "Clé API OpenCode Zen requise",
|
||||||
|
"chat.apiKeyRequiredDescription": "Saisissez votre clé API OpenCode pour activer le chat IA.",
|
||||||
|
"chat.apiKeyPlaceholder": "Saisissez votre clé API...",
|
||||||
|
"chat.apiKeySave": "Enregistrer la clé",
|
||||||
|
"chat.apiKeyValidating": "Validation...",
|
||||||
|
"chat.apiKeyInvalid": "Clé API invalide. Veuillez vérifier et réessayer.",
|
||||||
|
"chat.apiKeyValidationFailed": "Échec de validate API key.",
|
||||||
|
"chat.newChat": "Nouveau chat",
|
||||||
|
"chat.welcomeTitle": "Bienvenue dans l’assistant IA",
|
||||||
|
"chat.welcomeDescription": "I can help you manage your articles and médias. Try asking me to:",
|
||||||
|
"chat.welcomeTipSearch": "Recherche for articles about a specific topic",
|
||||||
|
"chat.welcomeTipDetails": "Get details about a specific article",
|
||||||
|
"chat.welcomeTipTags": "Lister toutes les étiquettes ou catégories de votre blog",
|
||||||
|
"chat.welcomeTipMetadata": "Update metadata for articles or médias",
|
||||||
|
"chat.welcomeTipImages": "List all images in your médias library",
|
||||||
|
"chat.role.you": "Vous",
|
||||||
|
"chat.role.assistant": "Assistant IA",
|
||||||
|
"chat.stop": "Arrêter",
|
||||||
|
"chat.inputPlaceholder": "Saisissez un message...",
|
||||||
|
"chat.errorPrefix": "Erreur : {error}",
|
||||||
|
"chat.errorNoResponse": "Échec de get a response. Please try again.",
|
||||||
|
"chat.errorEmptyResponse": "Le modèle a renvoyé une réponse vide. Essayez un autre modèle ou reformulez votre question.",
|
||||||
|
"chat.errorGeneric": "Désolé, une erreur est survenue lors du traitement de votre message.",
|
||||||
|
"chat.cancelledSuffix": "(annulé)",
|
||||||
|
"aiSuggestions.title": "Analyse d’image IA",
|
||||||
|
"aiSuggestions.close": "Fermer",
|
||||||
|
"aiSuggestions.analyzing": "Analyse de l’image...",
|
||||||
|
"aiSuggestions.titleField": "Titre",
|
||||||
|
"aiSuggestions.altField": "Texte alternatif",
|
||||||
|
"aiSuggestions.captionField": "Légende",
|
||||||
|
"aiSuggestions.hasExisting": "(valeur existante)",
|
||||||
|
"aiSuggestions.current": "Actuel",
|
||||||
|
"aiSuggestions.intro": "Sélectionnez les valeurs générées par l’IA à appliquer. Les valeurs existantes sont conservées par défaut.",
|
||||||
|
"aiSuggestions.empty": "Aucune suggestion n’a été générée pour cette image.",
|
||||||
|
"aiSuggestions.wait": "Veuillez patienter...",
|
||||||
|
"aiSuggestions.applySelected": "Appliquer la sélection",
|
||||||
|
"insert.title.link": "Insérer un lien",
|
||||||
|
"insert.title.image": "Insérer une image",
|
||||||
|
"insert.tab.linkInternal": "Lier à un article",
|
||||||
|
"insert.tab.imageInternal": "Bibliothèque média",
|
||||||
|
"insert.tab.linkExternal": "URL externe",
|
||||||
|
"insert.tab.imageExternal": "Image externe",
|
||||||
|
"insert.searchPlaceholder.link": "Recherche articles by title or content...",
|
||||||
|
"insert.searchPlaceholder.image": "Recherche médias by name, title, or alt text...",
|
||||||
|
"insert.status.searching": "Recherche...",
|
||||||
|
"insert.status.typeMore": "Saisissez au moins 2 caractères pour rechercher",
|
||||||
|
"insert.status.noResults": "Aucun(e) {kind} trouvé(e) pour \"{query}\"",
|
||||||
|
"insert.label.url": "Adresse URL",
|
||||||
|
"insert.label.linkTextOptional": "Texte du lien (optionnel)",
|
||||||
|
"insert.label.altText": "Texte alternatif",
|
||||||
|
"insert.placeholder.linkUrl": "https://exemple.fr",
|
||||||
|
"insert.placeholder.imageUrl": "https://exemple.fr/image.jpg",
|
||||||
|
"insert.placeholder.linkText": "Cliquez ici",
|
||||||
|
"insert.placeholder.imageAlt": "Description de l’image",
|
||||||
|
"insert.submit.link": "Insérer un lien",
|
||||||
|
"insert.submit.image": "Insérer une image",
|
||||||
|
"insert.hint.internal": "Utilisez ↑↓ pour naviguer, Entrée pour sélectionner, Esc pour fermer",
|
||||||
|
"insert.hint.external": "Entrez l’URL et appuyez sur Entrée ou cliquez sur le bouton, Esc pour fermer",
|
||||||
|
"insert.hint.canonicalPost": "Canonique : /YYYY/MM/DD/slug",
|
||||||
|
"insert.hint.canonicalMedia": "Canonical: /médias/YYYY/MM/file.ext",
|
||||||
|
"postLinks.loading": "Chargement des liens...",
|
||||||
|
"postLinks.link": "lien",
|
||||||
|
"postLinks.links": "liens",
|
||||||
|
"postLinks.linksTo": "Liens vers ({count})",
|
||||||
|
"postLinks.linkedBy": "Lié par ({count})",
|
||||||
|
"postLinks.openTitle": "Ouvrir: {title}",
|
||||||
|
"docs.title": "Guide utilisateur",
|
||||||
|
"docs.subtitle": "Guide utilisateur pour cette version installée de bDS.",
|
||||||
|
"gitDiff.header": "Diff : {target}",
|
||||||
|
"gitDiff.noProject": "Aucun projet actif sélectionné.",
|
||||||
|
"gitDiff.noProjectPath": "Impossible de résoudre le chemin du projet.",
|
||||||
|
"gitDiff.loadFailed": "Échec de load diff.",
|
||||||
|
"gitDiff.loading": "Chargement du diff...",
|
||||||
|
"gitDiff.changedFiles": "Fichiers modifiés",
|
||||||
|
"gitDiff.previousFile": "Fichier précédent",
|
||||||
|
"gitDiff.nextFile": "Fichier suivant",
|
||||||
|
"errorModal.error": "Erreur",
|
||||||
|
"errorModal.stackTrace": "Trace de pile",
|
||||||
|
"errorModal.copyClipboard": "Copier dans le presse-papiers",
|
||||||
|
"errorModal.copy": "Copier",
|
||||||
|
"errorModal.noStack": "Aucune trace de pile disponible",
|
||||||
|
"confirmDelete.title": "Confirmer la suppression",
|
||||||
|
"confirmDelete.promptPost": "Are you sure you want to delete the article",
|
||||||
|
"confirmDelete.promptMedia": "Are you sure you want to delete the médias file",
|
||||||
|
"confirmDelete.warning": "Avertissement :",
|
||||||
|
"confirmDelete.referencedBy": "Ce/cette {itemType} est référencé(e) par les éléments suivants :",
|
||||||
|
"confirmDelete.note": "La suppression de ce/cette {itemType} supprimera toutes ces références.",
|
||||||
|
"confirmDelete.cancel": "Annuler",
|
||||||
|
"confirmDelete.deletePost": "Supprimer l’article",
|
||||||
|
"confirmDelete.deleteMedia": "Supprimer le média",
|
||||||
|
"confirmDelete.itemType.post": "article",
|
||||||
|
"confirmDelete.itemType.media": "médias",
|
||||||
|
"lightbox.close": "Fermer (Esc)",
|
||||||
|
"lightbox.previous": "Précédent (←)",
|
||||||
|
"lightbox.next": "Suivant (→)",
|
||||||
|
"credentials.error.load": "Échec de load credentials:",
|
||||||
|
"credentials.error.save": "Échec de save credentials:",
|
||||||
|
"credentials.toast.saved": "Identifiants enregistrés",
|
||||||
|
"credentials.toast.saveFailed": "Échec de save credentials",
|
||||||
|
"credentials.toast.testing": "Test de la connexion {type}...",
|
||||||
|
"credentials.toast.connectionFailed": "Connection échoué - check credentials",
|
||||||
|
"credentials.tab.ftp": "Accès FTP",
|
||||||
|
"credentials.tab.ssh": "Accès SSH",
|
||||||
|
"credentials.ftp.title": "Publication FTP",
|
||||||
|
"credentials.ftp.description": "Configurez FTP pour publier votre blog sur un serveur web.",
|
||||||
|
"credentials.ssh.title": "Publication SSH",
|
||||||
|
"credentials.ssh.description": "Configurez SSH pour une publication sécurisée sur votre serveur.",
|
||||||
|
"credentials.field.host": "Hôte",
|
||||||
|
"credentials.field.username": "Nom d’utilisateur",
|
||||||
|
"credentials.field.password": "Mot de passe",
|
||||||
|
"credentials.field.sshKeyPath": "Chemin de clé SSH",
|
||||||
|
"credentials.action.testConnection": "Tester la connexion",
|
||||||
|
"credentials.ftp.placeholder.host": "ftp.exemple.fr",
|
||||||
|
"credentials.ftp.placeholder.username": "utilisateur-ftp",
|
||||||
|
"credentials.ftp.placeholder.password": "Mot de passe",
|
||||||
|
"credentials.ssh.placeholder.host": "serveur.exemple.fr",
|
||||||
|
"credentials.ssh.placeholder.username": "utilisateur-ssh",
|
||||||
|
"credentials.ssh.placeholder.keyPath": "~/.ssh/ma_cle",
|
||||||
|
"gitSidebar.header": "CONTRÔLE DE SOURCE",
|
||||||
|
"gitSidebar.loading": "Chargement...",
|
||||||
|
"gitSidebar.error.fetchRemoteUpdates": "Échec de fetch remote updates.",
|
||||||
|
"gitSidebar.error.refreshRemoteState": "Impossible d’actualiser l’état de suivi distant.",
|
||||||
|
"gitSidebar.error.gitMissing": "Exécutable Git introuvable. Veuillez installer Git et redémarrer l’application.",
|
||||||
|
"gitSidebar.error.noActiveProject": "Aucun projet actif sélectionné.",
|
||||||
|
"gitSidebar.error.loadRepoStatus": "Impossible de charger l’état du dépôt.",
|
||||||
|
"gitSidebar.error.initFailed": "Échec de initialize git repository.",
|
||||||
|
"gitSidebar.error.actionFailed": "Échec de {action}.",
|
||||||
|
"gitSidebar.error.commitFailed": "Échec de commit changes.",
|
||||||
|
"gitSidebar.progress.preparingInit": "Préparation de l’initialisation du dépôt...",
|
||||||
|
"gitSidebar.progress.pushingRemote": "Envoi des commits vers le distant... cela peut prendre un moment pour les gros envois.",
|
||||||
|
"gitSidebar.progress.fetching": "Récupération des mises à jour distantes...",
|
||||||
|
"gitSidebar.progress.pulling": "Récupération des dernières modifications...",
|
||||||
|
"gitSidebar.progress.pruningLfs": "Nettoyage du cache local Git LFS...",
|
||||||
|
"gitSidebar.progress.committing": "Création du commit...",
|
||||||
|
"gitSidebar.progress.initializingRepo": "Initialisation du dépôt...",
|
||||||
|
"gitSidebar.history.synced": "Synchronisé",
|
||||||
|
"gitSidebar.history.localOnly": "Local uniquement",
|
||||||
|
"gitSidebar.history.remoteOnly": "Distant uniquement",
|
||||||
|
"gitSidebar.init.transcript": "Journal d’initialisation",
|
||||||
|
"gitSidebar.aria.repoActions": "Actions du dépôt",
|
||||||
|
"gitSidebar.aria.openChanges": "Modifications ouvertes",
|
||||||
|
"gitSidebar.aria.commitStatusLegend": "Légende du statut de commit",
|
||||||
|
"gitSidebar.aria.versionHistory": "Historique des versions",
|
||||||
|
"gitSidebar.action.fetch": "Récupérer",
|
||||||
|
"gitSidebar.action.fetching": "Récupération...",
|
||||||
|
"gitSidebar.action.pull": "Tirer",
|
||||||
|
"gitSidebar.action.pulling": "Récupération des changements...",
|
||||||
|
"gitSidebar.action.push": "Pousser",
|
||||||
|
"gitSidebar.action.pushing": "Envoi...",
|
||||||
|
"gitSidebar.action.pruneLfs": "Purger LFS",
|
||||||
|
"gitSidebar.action.pruning": "Purge...",
|
||||||
|
"gitSidebar.action.commit": "Valider",
|
||||||
|
"gitSidebar.action.committing": "Commit en cours...",
|
||||||
|
"gitSidebar.action.initializeGit": "Initialiser Git",
|
||||||
|
"gitSidebar.action.initializing": "Initialisation...",
|
||||||
|
"gitSidebar.openChanges": "Ouvrir Changes ({count})",
|
||||||
|
"gitSidebar.versionHistory": "Historique des versions ({count})",
|
||||||
|
"gitSidebar.loadingChanges": "Chargement des modifications...",
|
||||||
|
"gitSidebar.noChanges": "Aucune modification",
|
||||||
|
"gitSidebar.loadingHistory": "Chargement de l’historique...",
|
||||||
|
"gitSidebar.noCommits": "Aucun commit pour le moment",
|
||||||
|
"gitSidebar.branch": "Branche : {branch}",
|
||||||
|
"gitSidebar.aheadBehind": "en avance {ahead} / en retard {behind}",
|
||||||
|
"gitSidebar.notRepo": "Ce projet n’est pas un dépôt git.",
|
||||||
|
"gitSidebar.placeholder.remoteUrl": "URL optionnelle du dépôt distant",
|
||||||
|
"gitSidebar.placeholder.commitMessage": "Message de commit",
|
||||||
|
"editor.untitled": "Sans titre",
|
||||||
|
"tabBar.style": "Apparence",
|
||||||
|
"tabBar.loading": "Chargement...",
|
||||||
|
"tabBar.unknown": "Inconnu",
|
||||||
|
"tabBar.preview": "Aperçu",
|
||||||
|
"tabBar.modified": "Modifié",
|
||||||
|
"tabBar.closeHint": "Fermer (Ctrl+W)",
|
||||||
|
"tabBar.scrollLeft": "Faire défiler les onglets vers la gauche",
|
||||||
|
"tabBar.scrollRight": "Faire défiler les onglets vers la droite",
|
||||||
|
"tabBar.commitTitle": "Validation {hash}",
|
||||||
|
"tabBar.error.fetchPostTitle": "Échec de fetch article title:",
|
||||||
|
"tabBar.error.fetchChatTitle": "Échec de fetch chat title:",
|
||||||
|
"tabBar.error.fetchImportTitle": "Échec de fetch import definition title:",
|
||||||
|
"tabBar.error.fetchCommitTitle": "Échec de fetch commit titles:",
|
||||||
|
"metadataDiff.title": "Outil de diff des métadonnées",
|
||||||
|
"metadataDiff.description": "Compare article metadata between database and markdown files. Fix inconsistencies caused by bugs or manual edits.",
|
||||||
|
"metadataDiff.error.loadStats": "Échec de load database statistics",
|
||||||
|
"metadataDiff.error.scan": "Échec de scan for differences",
|
||||||
|
"metadataDiff.progress.starting": "Démarrage de l’analyse...",
|
||||||
|
"metadataDiff.progress.scanningPublished": "Scanning published articles...",
|
||||||
|
"metadataDiff.progress.scanning": "Analyse en cours...",
|
||||||
|
"metadataDiff.action.scan": "Analyser les différences",
|
||||||
|
"metadataDiff.action.rescan": "Relancer l’analyse",
|
||||||
|
"metadataDiff.stats.totalPosts": "Articles au total",
|
||||||
|
"metadataDiff.stats.published": "Publiés",
|
||||||
|
"metadataDiff.stats.drafts": "Brouillons",
|
||||||
|
"metadataDiff.stats.mediaFiles": "Fichiers média",
|
||||||
|
"metadataDiff.summary.noDiffs": "✅ No differences found! All {total} published articles are in sync.",
|
||||||
|
"metadataDiff.summary.withDiffs": "⚠️ Found {count} articles with differences out of {total} published articles.",
|
||||||
|
"metadataDiff.group.differences": "Différences de {label}",
|
||||||
|
"metadataDiff.group.postsCount": "{count} articles",
|
||||||
|
"metadataDiff.sync.failed": "échoué",
|
||||||
|
"metadataDiff.sync.dbToFile.title": "Mettre à jour les fichiers avec les valeurs de la base",
|
||||||
|
"metadataDiff.sync.dbToFile.success": "Synced {success} articles to files{échoué}",
|
||||||
|
"metadataDiff.sync.dbToFile.error": "Échec de sync to files",
|
||||||
|
"metadataDiff.sync.fileToDb.title": "Mettre à jour la base avec les valeurs des fichiers",
|
||||||
|
"metadataDiff.sync.fileToDb.success": "Synced {success} files to database{échoué}",
|
||||||
|
"metadataDiff.sync.fileToDb.error": "Échec de sync to database",
|
||||||
|
"metadataDiff.value.database": "Base de données",
|
||||||
|
"metadataDiff.value.file": "Fichier",
|
||||||
|
"metadataDiff.empty": "Cliquez sur « Rechercher les différences » pour comparer les métadonnées de la base et celles des fichiers."
|
||||||
|
}
|
||||||
320
src/renderer/i18n/locales/it.json
Normal file
320
src/renderer/i18n/locales/it.json
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
{
|
||||||
|
"common.save": "Salva",
|
||||||
|
"common.cancel": "Annulla",
|
||||||
|
"common.clear": "Cancella",
|
||||||
|
"common.settings": "Impostazioni",
|
||||||
|
"common.tasks": "Attività",
|
||||||
|
"common.running": "in esecuzione",
|
||||||
|
"common.pending": "in attesa",
|
||||||
|
"activity.posts": "Post",
|
||||||
|
"activity.pages": "Pagine",
|
||||||
|
"activity.media": "Contenuti media",
|
||||||
|
"activity.tags": "Tag",
|
||||||
|
"activity.aiAssistant": "Assistente IA",
|
||||||
|
"activity.import": "Importa",
|
||||||
|
"activity.sourceControl": "Controllo sorgente",
|
||||||
|
"activity.toggleHint": "(clicca di nuovo per mostrare/nascondere la barra laterale)",
|
||||||
|
"tasks.backgroundTasks": "Attività in background",
|
||||||
|
"tasks.clearCompleted": "Cancella completate",
|
||||||
|
"tasks.recent": "Recenti",
|
||||||
|
"tasks.noActive": "Nessuna attività attiva",
|
||||||
|
"tasks.cancelTask": "Annulla attività",
|
||||||
|
"tasks.triggerTitle": "{running} in esecuzione, {pending} in attesa",
|
||||||
|
"app.taskCompleted": "Attività completata: {message}",
|
||||||
|
"app.taskFailed": "Attività non riuscita: {message}",
|
||||||
|
"app.databaseRebuildFailed": "Ricostruzione database non riuscita",
|
||||||
|
"app.textReindexFailed": "Reindicizzazione testo non riuscita",
|
||||||
|
"app.sitemapGenerationFailed": "Generazione sitemap non riuscita",
|
||||||
|
"app.previewOpenFailed": "Impossibile aprire l’anteprima del post selezionato",
|
||||||
|
"app.metadataDiff": "Diff Metadati",
|
||||||
|
"app.importComplete": "Import completato: {posts} post, {media} file multimediali",
|
||||||
|
"settings.language.english": "Inglese",
|
||||||
|
"settings.language.german": "Tedesco",
|
||||||
|
"settings.language.french": "Francese",
|
||||||
|
"settings.language.italian": "Italiano",
|
||||||
|
"settings.language.spanish": "Spagnolo",
|
||||||
|
"settings.language.portuguese": "Portoghese (Português)",
|
||||||
|
"settings.language.dutch": "Olandese (Nederlands)",
|
||||||
|
"settings.language.polish": "Polacco (Polski)",
|
||||||
|
"settings.language.russian": "Russo (Русский)",
|
||||||
|
"settings.language.japanese": "Giapponese (日本語)",
|
||||||
|
"settings.language.chinese": "Cinese (中文)",
|
||||||
|
"settings.language.korean": "Coreano (한국어)",
|
||||||
|
"settings.language.arabic": "Arabo (العربية)",
|
||||||
|
"settings.language.hindi": "Hindi",
|
||||||
|
"settings.language.turkish": "Turco (Türkçe)",
|
||||||
|
"settings.language.swedish": "Svedese (Svenska)",
|
||||||
|
"settings.language.danish": "Danese (Dansk)",
|
||||||
|
"settings.language.norwegian": "Norvegese (Norsk)",
|
||||||
|
"settings.language.finnish": "Finlandese (Suomi)",
|
||||||
|
"settings.language.czech": "Ceco (Čeština)",
|
||||||
|
"settings.project.title": "Progetto",
|
||||||
|
"settings.project.browse": "Sfoglia",
|
||||||
|
"settings.project.reset": "Reimposta",
|
||||||
|
"settings.project.resetDefault": "Ripristina predefinito",
|
||||||
|
"settings.project.selectDataFolder": "Seleziona cartella dati del progetto",
|
||||||
|
"settings.editor.title": "Editor di testo",
|
||||||
|
"settings.editor.mode.wysiwyg": "WYSIWYG (editor visuale)",
|
||||||
|
"settings.editor.mode.markdown": "Markdown (sorgente)",
|
||||||
|
"settings.editor.mode.preview": "Anteprima (sola lettura)",
|
||||||
|
"settings.editor.diff.inline": "In linea",
|
||||||
|
"settings.editor.diff.sideBySide": "Affiancato",
|
||||||
|
"settings.content.title": "Categorie post",
|
||||||
|
"settings.content.renderInLists": "Mostra negli elenchi",
|
||||||
|
"settings.content.showTitles": "Mostra titoli",
|
||||||
|
"settings.ai.title": "Assistente IA",
|
||||||
|
"settings.ai.noModels": "Nessun modello disponibile",
|
||||||
|
"settings.publishing.ftpTitle": "Pubblicazione FTP",
|
||||||
|
"settings.publishing.sshTitle": "Pubblicazione SSH",
|
||||||
|
"settings.data.title": "Manutenzione database",
|
||||||
|
"settings.data.fileSystemTitle": "File system",
|
||||||
|
"settings.search.placeholder": "Cerca impostazioni...",
|
||||||
|
"settings.search.noResults": "No impostazioni found matching \"{query}\"",
|
||||||
|
"settings.search.clear": "Cancella ricerca",
|
||||||
|
"settings.toast.publishingSaved": "Credenziali di pubblicazione salvate",
|
||||||
|
"settings.toast.saveCredentialsFailed": "Impossibile save credentials",
|
||||||
|
"settings.toast.credentialsCleared": "Credenziali {type} cancellate",
|
||||||
|
"settings.toast.projectSaved": "Project impostazioni saved",
|
||||||
|
"settings.toast.projectSaveFailed": "Impossibile save project impostazioni",
|
||||||
|
"settings.toast.categoryAdded": "Categoria \"{category}\" aggiunta",
|
||||||
|
"settings.toast.categoryAddFailed": "Impossibile add category",
|
||||||
|
"settings.toast.categoryExists": "La categoria esiste già",
|
||||||
|
"settings.toast.categoryProtected": "Impossibile eliminare la categoria standard \"{category}\"",
|
||||||
|
"settings.toast.categoryAtLeastOne": "Deve esserci almeno una categoria",
|
||||||
|
"settings.toast.categoryRemoved": "Categoria \"{category}\" rimossa",
|
||||||
|
"settings.toast.categoryRemoveFailed": "Impossibile remove category",
|
||||||
|
"settings.toast.categoriesReset": "Categorie ripristinate ai predefiniti",
|
||||||
|
"settings.toast.categoriesResetFailed": "Impossibile reset categories",
|
||||||
|
"settings.toast.categorySettingsUpdateFailed": "Impossibile update category impostazioni",
|
||||||
|
"settings.toast.systemPromptSaved": "Prompt di sistema salvato",
|
||||||
|
"settings.toast.systemPromptSaveFailed": "Impossibile save system prompt",
|
||||||
|
"settings.toast.systemPromptReset": "Prompt di sistema ripristinato al predefinito",
|
||||||
|
"settings.toast.systemPromptResetFailed": "Impossibile reset system prompt",
|
||||||
|
"settings.toast.apiKeySaved": "Chiave API salvata e convalidata",
|
||||||
|
"settings.toast.apiKeyInvalid": "Chiave API non valida",
|
||||||
|
"settings.toast.apiKeySaveFailed": "Impossibile save API key",
|
||||||
|
"settings.toast.defaultModelUpdated": "Modello predefinito aggiornato",
|
||||||
|
"settings.toast.defaultModelUpdateFailed": "Impossibile set default model",
|
||||||
|
"settings.toast.rebuildPostsLoading": "Rebuilding post database...",
|
||||||
|
"settings.toast.rebuildPostsSuccess": "Database post ricostruito",
|
||||||
|
"settings.toast.rebuildPostsFailed": "Impossibile rebuild post database",
|
||||||
|
"settings.toast.rebuildMediaLoading": "Ricostruzione database media...",
|
||||||
|
"settings.toast.rebuildMediaSuccess": "Database media ricostruito",
|
||||||
|
"settings.toast.rebuildMediaFailed": "Impossibile rebuild media database",
|
||||||
|
"settings.toast.rebuildLinksLoading": "Ricostruzione dei link dei post...",
|
||||||
|
"settings.toast.rebuildLinksSuccess": "Link dei post ricostruiti",
|
||||||
|
"settings.toast.rebuildLinksFailed": "Impossibile rebuild post links",
|
||||||
|
"settings.toast.thumbnailsLoading": "Generazione miniature in corso...",
|
||||||
|
"settings.toast.thumbnailsGenerated": "Generate {count} miniature",
|
||||||
|
"settings.toast.thumbnailsAlreadyExist": "Tutte le miniature esistono già",
|
||||||
|
"settings.toast.thumbnailsComplete": "Generazione miniature completata",
|
||||||
|
"settings.toast.thumbnailsFailed": "Impossibile generate thumbnails",
|
||||||
|
"chat.setupTitle": "Configurazione chat IA",
|
||||||
|
"chat.apiKeyRequiredTitle": "Chiave API OpenCode Zen richiesta",
|
||||||
|
"chat.apiKeyRequiredDescription": "Inserisci la tua chiave API OpenCode per abilitare la chat IA.",
|
||||||
|
"chat.apiKeyPlaceholder": "Inserisci la tua chiave API...",
|
||||||
|
"chat.apiKeySave": "Salva chiave",
|
||||||
|
"chat.apiKeyValidating": "Convalida in corso...",
|
||||||
|
"chat.apiKeyInvalid": "Chiave API non valida. Controlla e riprova.",
|
||||||
|
"chat.apiKeyValidationFailed": "Impossibile validate API key.",
|
||||||
|
"chat.newChat": "Nuova chat",
|
||||||
|
"chat.welcomeTitle": "Benvenuto nell’assistente IA",
|
||||||
|
"chat.welcomeDescription": "I can help you manage your post and media. Try asking me to:",
|
||||||
|
"chat.welcomeTipSearch": "Ricerca for post about a specific topic",
|
||||||
|
"chat.welcomeTipDetails": "Ottieni dettagli su un post specifico",
|
||||||
|
"chat.welcomeTipTags": "Elenca tutti i tag o le categorie del tuo blog",
|
||||||
|
"chat.welcomeTipMetadata": "Update metadata for post or media",
|
||||||
|
"chat.welcomeTipImages": "Elenca tutte le immagini nella tua libreria media",
|
||||||
|
"chat.role.you": "Tu",
|
||||||
|
"chat.role.assistant": "Assistente",
|
||||||
|
"chat.stop": "Ferma",
|
||||||
|
"chat.inputPlaceholder": "Scrivi un messaggio...",
|
||||||
|
"chat.errorPrefix": "Errore: {error}",
|
||||||
|
"chat.errorNoResponse": "Impossibile get a response. Please try again.",
|
||||||
|
"chat.errorEmptyResponse": "Il modello ha restituito una risposta vuota. Prova un modello diverso o riformula la domanda.",
|
||||||
|
"chat.errorGeneric": "Sorry, an error occurred while processing your messaggio.",
|
||||||
|
"chat.cancelledSuffix": "(annullato)",
|
||||||
|
"aiSuggestions.title": "Analisi immagine IA",
|
||||||
|
"aiSuggestions.close": "Chiudi",
|
||||||
|
"aiSuggestions.analyzing": "Analisi dell’immagine in corso...",
|
||||||
|
"aiSuggestions.titleField": "Titolo",
|
||||||
|
"aiSuggestions.altField": "Testo alternativo",
|
||||||
|
"aiSuggestions.captionField": "Didascalia",
|
||||||
|
"aiSuggestions.hasExisting": "(ha un valore esistente)",
|
||||||
|
"aiSuggestions.current": "Attuale",
|
||||||
|
"aiSuggestions.intro": "Seleziona quali valori generati dall’IA applicare. I valori esistenti vengono mantenuti per impostazione predefinita.",
|
||||||
|
"aiSuggestions.empty": "Nessun suggerimento è stato generato per questa immagine.",
|
||||||
|
"aiSuggestions.wait": "Attendere...",
|
||||||
|
"aiSuggestions.applySelected": "Applica selezionati",
|
||||||
|
"insert.title.link": "Inserisci link",
|
||||||
|
"insert.title.image": "Inserisci immagine",
|
||||||
|
"insert.tab.linkInternal": "Collega al post",
|
||||||
|
"insert.tab.imageInternal": "Libreria media",
|
||||||
|
"insert.tab.linkExternal": "URL esterno",
|
||||||
|
"insert.tab.imageExternal": "Immagine esterna",
|
||||||
|
"insert.searchPlaceholder.link": "Ricerca post by title or content...",
|
||||||
|
"insert.searchPlaceholder.image": "Ricerca media by name, title, or alt text...",
|
||||||
|
"insert.status.searching": "Ricerca...",
|
||||||
|
"insert.status.typeMore": "Digita almeno 2 caratteri per cercare",
|
||||||
|
"insert.status.noResults": "Nessun {kind} trovato per \"{query}\"",
|
||||||
|
"insert.label.url": "Indirizzo URL",
|
||||||
|
"insert.label.linkTextOptional": "Testo link (opzionale)",
|
||||||
|
"insert.label.altText": "Testo alternativo",
|
||||||
|
"insert.placeholder.linkUrl": "https://esempio.it",
|
||||||
|
"insert.placeholder.imageUrl": "https://esempio.it/immagine.jpg",
|
||||||
|
"insert.placeholder.linkText": "Clicca qui",
|
||||||
|
"insert.placeholder.imageAlt": "Descrizione dell’immagine",
|
||||||
|
"insert.submit.link": "Inserisci link",
|
||||||
|
"insert.submit.image": "Inserisci immagine",
|
||||||
|
"insert.hint.internal": "Usa ↑↓ per navigare, Invio per selezionare, Esc per chiudere",
|
||||||
|
"insert.hint.external": "Inserisci URL e premi Invio o clicca il pulsante, Esc per chiudere",
|
||||||
|
"insert.hint.canonicalPost": "Canonico: /YYYY/MM/DD/slug",
|
||||||
|
"insert.hint.canonicalMedia": "Canonico: /media/YYYY/MM/file.ext",
|
||||||
|
"postLinks.loading": "Caricamento link...",
|
||||||
|
"postLinks.link": "collegamento",
|
||||||
|
"postLinks.links": "link",
|
||||||
|
"postLinks.linksTo": "Collega a ({count})",
|
||||||
|
"postLinks.linkedBy": "Collegato da ({count})",
|
||||||
|
"postLinks.openTitle": "Apri: {title}",
|
||||||
|
"docs.title": "Documentazione",
|
||||||
|
"docs.subtitle": "Guida utente per questa versione installata di bDS.",
|
||||||
|
"gitDiff.header": "Differenza: {target}",
|
||||||
|
"gitDiff.noProject": "Nessun progetto attivo selezionato.",
|
||||||
|
"gitDiff.noProjectPath": "Impossibile risolvere il percorso del progetto.",
|
||||||
|
"gitDiff.loadFailed": "Impossibile load diff.",
|
||||||
|
"gitDiff.loading": "Caricamento diff...",
|
||||||
|
"gitDiff.changedFiles": "File modificati",
|
||||||
|
"gitDiff.previousFile": "File precedente",
|
||||||
|
"gitDiff.nextFile": "File successivo",
|
||||||
|
"errorModal.error": "Errore",
|
||||||
|
"errorModal.stackTrace": "Traccia dello stack",
|
||||||
|
"errorModal.copyClipboard": "Copia negli appunti",
|
||||||
|
"errorModal.copy": "Copia",
|
||||||
|
"errorModal.noStack": "Nessuna traccia dello stack disponibile",
|
||||||
|
"confirmDelete.title": "Conferma eliminazione",
|
||||||
|
"confirmDelete.promptPost": "Sei sicuro di voler eliminare il post",
|
||||||
|
"confirmDelete.promptMedia": "Sei sicuro di voler eliminare il file multimediale",
|
||||||
|
"confirmDelete.warning": "Attenzione:",
|
||||||
|
"confirmDelete.referencedBy": "Questo {itemType} è referenziato dai seguenti elementi:",
|
||||||
|
"confirmDelete.note": "L’eliminazione di questo {itemType} rimuoverà tutti questi riferimenti.",
|
||||||
|
"confirmDelete.cancel": "Annulla",
|
||||||
|
"confirmDelete.deletePost": "Elimina post",
|
||||||
|
"confirmDelete.deleteMedia": "Elimina media",
|
||||||
|
"confirmDelete.itemType.post": "articolo",
|
||||||
|
"confirmDelete.itemType.media": "file multimediale",
|
||||||
|
"lightbox.close": "Chiudi (Esc)",
|
||||||
|
"lightbox.previous": "Precedente (←)",
|
||||||
|
"lightbox.next": "Successivo (→)",
|
||||||
|
"credentials.error.load": "Impossibile load credentials:",
|
||||||
|
"credentials.error.save": "Impossibile save credentials:",
|
||||||
|
"credentials.toast.saved": "Credenziali salvate",
|
||||||
|
"credentials.toast.saveFailed": "Impossibile save credentials",
|
||||||
|
"credentials.toast.testing": "Test della connessione {type} in corso...",
|
||||||
|
"credentials.toast.connectionFailed": "Connection fallito - check credentials",
|
||||||
|
"credentials.tab.ftp": "Accesso FTP",
|
||||||
|
"credentials.tab.ssh": "Accesso SSH",
|
||||||
|
"credentials.ftp.title": "Pubblicazione FTP",
|
||||||
|
"credentials.ftp.description": "Configura FTP per pubblicare il tuo blog su un server web.",
|
||||||
|
"credentials.ssh.title": "Pubblicazione SSH",
|
||||||
|
"credentials.ssh.description": "Configura SSH per una pubblicazione sicura sul tuo server.",
|
||||||
|
"credentials.field.host": "Server",
|
||||||
|
"credentials.field.username": "Nome utente",
|
||||||
|
"credentials.field.password": "Password di accesso",
|
||||||
|
"credentials.field.sshKeyPath": "Percorso chiave SSH",
|
||||||
|
"credentials.action.testConnection": "Testa connessione",
|
||||||
|
"credentials.ftp.placeholder.host": "ftp.esempio.it",
|
||||||
|
"credentials.ftp.placeholder.username": "utente-ftp",
|
||||||
|
"credentials.ftp.placeholder.password": "Inserisci password",
|
||||||
|
"credentials.ssh.placeholder.host": "server.esempio.it",
|
||||||
|
"credentials.ssh.placeholder.username": "utente-ssh",
|
||||||
|
"credentials.ssh.placeholder.keyPath": "~/.ssh/chiave_id_rsa",
|
||||||
|
"gitSidebar.header": "CONTROLLO SORGENTE",
|
||||||
|
"gitSidebar.loading": "Caricamento...",
|
||||||
|
"gitSidebar.error.fetchRemoteUpdates": "Impossibile fetch remote updates.",
|
||||||
|
"gitSidebar.error.refreshRemoteState": "Impossibile aggiornare lo stato di tracciamento remoto.",
|
||||||
|
"gitSidebar.error.gitMissing": "Eseguibile Git non trovato. Installa Git e riavvia l’app.",
|
||||||
|
"gitSidebar.error.noActiveProject": "Nessun progetto attivo selezionato.",
|
||||||
|
"gitSidebar.error.loadRepoStatus": "Impossibile caricare lo stato del repository.",
|
||||||
|
"gitSidebar.error.initFailed": "Impossibile initialize git repository.",
|
||||||
|
"gitSidebar.error.actionFailed": "Impossibile {action}.",
|
||||||
|
"gitSidebar.error.commitFailed": "Impossibile commit changes.",
|
||||||
|
"gitSidebar.progress.preparingInit": "Preparazione inizializzazione repository...",
|
||||||
|
"gitSidebar.progress.pushingRemote": "Invio dei commit al remoto... può richiedere tempo per upload grandi.",
|
||||||
|
"gitSidebar.progress.fetching": "Recupero aggiornamenti remoti...",
|
||||||
|
"gitSidebar.progress.pulling": "Recupero ultime modifiche...",
|
||||||
|
"gitSidebar.progress.pruningLfs": "Pulizia cache locale Git LFS...",
|
||||||
|
"gitSidebar.progress.committing": "Creazione commit...",
|
||||||
|
"gitSidebar.progress.initializingRepo": "Inizializzazione repository...",
|
||||||
|
"gitSidebar.history.synced": "Sincronizzato",
|
||||||
|
"gitSidebar.history.localOnly": "Solo locale",
|
||||||
|
"gitSidebar.history.remoteOnly": "Solo remoto",
|
||||||
|
"gitSidebar.init.transcript": "Registro di inizializzazione",
|
||||||
|
"gitSidebar.aria.repoActions": "Azioni repository",
|
||||||
|
"gitSidebar.aria.openChanges": "Modifiche aperte",
|
||||||
|
"gitSidebar.aria.commitStatusLegend": "Legenda stato commit",
|
||||||
|
"gitSidebar.aria.versionHistory": "Cronologia versioni",
|
||||||
|
"gitSidebar.action.fetch": "Recupera",
|
||||||
|
"gitSidebar.action.fetching": "Recupero...",
|
||||||
|
"gitSidebar.action.pull": "Esegui pull",
|
||||||
|
"gitSidebar.action.pulling": "Pull in corso...",
|
||||||
|
"gitSidebar.action.push": "Esegui push",
|
||||||
|
"gitSidebar.action.pushing": "Push in corso...",
|
||||||
|
"gitSidebar.action.pruneLfs": "Pulisci LFS",
|
||||||
|
"gitSidebar.action.pruning": "Pulizia...",
|
||||||
|
"gitSidebar.action.commit": "Registra commit",
|
||||||
|
"gitSidebar.action.committing": "Commit in corso...",
|
||||||
|
"gitSidebar.action.initializeGit": "Inizializza Git",
|
||||||
|
"gitSidebar.action.initializing": "Inizializzazione...",
|
||||||
|
"gitSidebar.openChanges": "Apri Changes ({count})",
|
||||||
|
"gitSidebar.versionHistory": "Cronologia versioni ({count})",
|
||||||
|
"gitSidebar.loadingChanges": "Caricamento modifiche...",
|
||||||
|
"gitSidebar.noChanges": "Nessuna modifica",
|
||||||
|
"gitSidebar.loadingHistory": "Caricamento cronologia...",
|
||||||
|
"gitSidebar.noCommits": "Nessun commit ancora",
|
||||||
|
"gitSidebar.branch": "Ramo: {branch}",
|
||||||
|
"gitSidebar.aheadBehind": "avanti {ahead} / indietro {behind}",
|
||||||
|
"gitSidebar.notRepo": "Questo progetto non è un repository git.",
|
||||||
|
"gitSidebar.placeholder.remoteUrl": "URL facoltativo del repository remoto",
|
||||||
|
"gitSidebar.placeholder.commitMessage": "Messaggio di commit",
|
||||||
|
"editor.untitled": "Senza titolo",
|
||||||
|
"tabBar.style": "Stile",
|
||||||
|
"tabBar.loading": "Caricamento...",
|
||||||
|
"tabBar.unknown": "Sconosciuto",
|
||||||
|
"tabBar.preview": "Anteprima",
|
||||||
|
"tabBar.modified": "Modificato",
|
||||||
|
"tabBar.closeHint": "Chiudi (Ctrl+W)",
|
||||||
|
"tabBar.scrollLeft": "Scorri le schede a sinistra",
|
||||||
|
"tabBar.scrollRight": "Scorri le schede a destra",
|
||||||
|
"tabBar.commitTitle": "Revisione {hash}",
|
||||||
|
"tabBar.error.fetchPostTitle": "Impossibile fetch post title:",
|
||||||
|
"tabBar.error.fetchChatTitle": "Impossibile fetch chat title:",
|
||||||
|
"tabBar.error.fetchImportTitle": "Impossibile fetch import definition title:",
|
||||||
|
"tabBar.error.fetchCommitTitle": "Impossibile fetch commit titles:",
|
||||||
|
"metadataDiff.title": "Strumento diff metadati",
|
||||||
|
"metadataDiff.description": "Confronta i metadati dei post tra database e file markdown. Correggi incongruenze causate da bug o modifiche manuali.",
|
||||||
|
"metadataDiff.error.loadStats": "Impossibile load database statistics",
|
||||||
|
"metadataDiff.error.scan": "Impossibile scan for differences",
|
||||||
|
"metadataDiff.progress.starting": "Avvio scansione...",
|
||||||
|
"metadataDiff.progress.scanningPublished": "Scanning published post...",
|
||||||
|
"metadataDiff.progress.scanning": "Scansione in corso...",
|
||||||
|
"metadataDiff.action.scan": "Cerca differenze",
|
||||||
|
"metadataDiff.action.rescan": "Riesegui scansione",
|
||||||
|
"metadataDiff.stats.totalPosts": "Post totali",
|
||||||
|
"metadataDiff.stats.published": "Pubblicati",
|
||||||
|
"metadataDiff.stats.drafts": "Bozze",
|
||||||
|
"metadataDiff.stats.mediaFiles": "File multimediali",
|
||||||
|
"metadataDiff.summary.noDiffs": "✅ No differences found! All {total} published post are in sync.",
|
||||||
|
"metadataDiff.summary.withDiffs": "⚠️ Found {count} post with differences out of {total} published post.",
|
||||||
|
"metadataDiff.group.differences": "Differenze {label}",
|
||||||
|
"metadataDiff.group.postsCount": "{count} post",
|
||||||
|
"metadataDiff.sync.failed": "fallito",
|
||||||
|
"metadataDiff.sync.dbToFile.title": "Aggiorna i file con i valori del database",
|
||||||
|
"metadataDiff.sync.dbToFile.success": "Synced {success} post to files{fallito}",
|
||||||
|
"metadataDiff.sync.dbToFile.error": "Impossibile sync to files",
|
||||||
|
"metadataDiff.sync.fileToDb.title": "Aggiorna il database con i valori dei file",
|
||||||
|
"metadataDiff.sync.fileToDb.success": "Synced {success} files to database{fallito}",
|
||||||
|
"metadataDiff.sync.fileToDb.error": "Impossibile sync to database",
|
||||||
|
"metadataDiff.value.database": "Database locale",
|
||||||
|
"metadataDiff.value.file": "File sorgente",
|
||||||
|
"metadataDiff.empty": "Fai clic su \"Scansiona differenze\" per confrontare i metadati del database con quelli dei file."
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
|
|||||||
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
|
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
|
||||||
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
|
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { I18nProvider } from './i18n';
|
||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
|
|
||||||
// Configure Monaco web workers
|
// Configure Monaco web workers
|
||||||
@@ -37,6 +38,8 @@ loader.config({ monaco });
|
|||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<I18nProvider>
|
||||||
|
<App />
|
||||||
|
</I18nProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -578,10 +578,10 @@ describe('PreviewServer', () => {
|
|||||||
expect(firstPageHtml).toContain('<h1 class="archive-heading">Meine Blog Beschreibung</h1>');
|
expect(firstPageHtml).toContain('<h1 class="archive-heading">Meine Blog Beschreibung</h1>');
|
||||||
|
|
||||||
const secondPageHtml = await (await fetch(`${server.getBaseUrl()}/page/2/`)).text();
|
const secondPageHtml = await (await fetch(`${server.getBaseUrl()}/page/2/`)).text();
|
||||||
expect(secondPageHtml).toContain('<h1 class="archive-heading">Archiv 1.1.2020 - 2.1.2020</h1>');
|
expect(secondPageHtml).toContain('<h1 class="archive-heading">Archive 1.1.2020 - 2.1.2020</h1>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders month archive heading with German month name on first page', async () => {
|
it('renders month archive heading in the active render language on first page', async () => {
|
||||||
const posts = [
|
const posts = [
|
||||||
makePost({ id: 'm-1', slug: 'm-1', title: 'M1', content: 'Body 1', createdAt: new Date('2020-02-05T10:00:00.000Z') }),
|
makePost({ id: 'm-1', slug: 'm-1', title: 'M1', content: 'Body 1', createdAt: new Date('2020-02-05T10:00:00.000Z') }),
|
||||||
makePost({ id: 'm-2', slug: 'm-2', title: 'M2', content: 'Body 2', createdAt: new Date('2020-02-04T10:00:00.000Z') }),
|
makePost({ id: 'm-2', slug: 'm-2', title: 'M2', content: 'Body 2', createdAt: new Date('2020-02-04T10:00:00.000Z') }),
|
||||||
@@ -604,7 +604,7 @@ describe('PreviewServer', () => {
|
|||||||
await server.start(0);
|
await server.start(0);
|
||||||
|
|
||||||
const monthPageHtml = await (await fetch(`${server.getBaseUrl()}/2020/2/`)).text();
|
const monthPageHtml = await (await fetch(`${server.getBaseUrl()}/2020/2/`)).text();
|
||||||
expect(monthPageHtml).toContain('<h1 class="archive-heading">Archiv Februar 2020</h1>');
|
expect(monthPageHtml).toContain('<h1 class="archive-heading">Archive February 2020</h1>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders tag heading on first page and adds date range on later pages', async () => {
|
it('renders tag heading on first page and adds date range on later pages', async () => {
|
||||||
|
|||||||
27
tests/engine/i18nLocaleCompleteness.test.ts
Normal file
27
tests/engine/i18nLocaleCompleteness.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import en from '../../src/main/shared/i18n/locales/en.json';
|
||||||
|
import de from '../../src/main/shared/i18n/locales/de.json';
|
||||||
|
import fr from '../../src/main/shared/i18n/locales/fr.json';
|
||||||
|
import itLocale from '../../src/main/shared/i18n/locales/it.json';
|
||||||
|
import es from '../../src/main/shared/i18n/locales/es.json';
|
||||||
|
|
||||||
|
type LocaleMap = Record<string, string>;
|
||||||
|
|
||||||
|
const locales: Record<string, LocaleMap> = {
|
||||||
|
de: de as LocaleMap,
|
||||||
|
fr: fr as LocaleMap,
|
||||||
|
it: itLocale as LocaleMap,
|
||||||
|
es: es as LocaleMap,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('main/shared locale completeness', () => {
|
||||||
|
const englishKeys = Object.keys(en as LocaleMap).sort();
|
||||||
|
|
||||||
|
it('all main/shared locales contain exactly the english key set', () => {
|
||||||
|
for (const [locale, messages] of Object.entries(locales)) {
|
||||||
|
const localeKeys = Object.keys(messages).sort();
|
||||||
|
|
||||||
|
expect(localeKeys, `Locale ${locale} is missing or has extra keys`).toEqual(englishKeys);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
26
tests/engine/i18nRenderLanguage.test.ts
Normal file
26
tests/engine/i18nRenderLanguage.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
resolveSupportedRenderLanguage,
|
||||||
|
resolveRenderLanguageFromProjectPreferences,
|
||||||
|
translateRender,
|
||||||
|
} from '../../src/main/shared/i18n';
|
||||||
|
|
||||||
|
describe('render i18n', () => {
|
||||||
|
it('resolves rendering language from project preferences', () => {
|
||||||
|
expect(resolveRenderLanguageFromProjectPreferences('de')).toBe('de');
|
||||||
|
expect(resolveRenderLanguageFromProjectPreferences('fr-CA')).toBe('fr');
|
||||||
|
expect(resolveRenderLanguageFromProjectPreferences(undefined)).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes render language values', () => {
|
||||||
|
expect(resolveSupportedRenderLanguage('it')).toBe('it');
|
||||||
|
expect(resolveSupportedRenderLanguage('es-AR')).toBe('es');
|
||||||
|
expect(resolveSupportedRenderLanguage('')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates render keys with fallback', () => {
|
||||||
|
expect(translateRender('de', 'render.pagination.newer')).toBe('neuer');
|
||||||
|
expect(translateRender('es', 'render.pagination.older')).toBe('más antiguo');
|
||||||
|
expect(translateRender('fr', 'missing.key')).toBe('missing.key');
|
||||||
|
});
|
||||||
|
});
|
||||||
27
tests/renderer/i18n.test.ts
Normal file
27
tests/renderer/i18n.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
translateUi,
|
||||||
|
resolveSupportedUiLanguage,
|
||||||
|
resolveUiLanguageFromSystemLocale,
|
||||||
|
} from '../../src/renderer/i18n';
|
||||||
|
|
||||||
|
describe('renderer i18n', () => {
|
||||||
|
it('resolves supported ui language from OS locale', () => {
|
||||||
|
expect(resolveUiLanguageFromSystemLocale('de-DE')).toBe('de');
|
||||||
|
expect(resolveUiLanguageFromSystemLocale('fr-CH')).toBe('fr');
|
||||||
|
expect(resolveUiLanguageFromSystemLocale('pt-BR')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes explicit ui language values', () => {
|
||||||
|
expect(resolveSupportedUiLanguage('it')).toBe('it');
|
||||||
|
expect(resolveSupportedUiLanguage('es-MX')).toBe('es');
|
||||||
|
expect(resolveSupportedUiLanguage('')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns translated text with english fallback', () => {
|
||||||
|
expect(translateUi('de', 'common.save')).toBe('Speichern');
|
||||||
|
expect(translateUi('fr', 'common.cancel')).toBe('Annuler');
|
||||||
|
expect(translateUi('de', 'settings.language.english')).toBe('Englisch');
|
||||||
|
expect(translateUi('it', 'missing.key')).toBe('missing.key');
|
||||||
|
});
|
||||||
|
});
|
||||||
27
tests/renderer/i18nLocaleCompleteness.test.ts
Normal file
27
tests/renderer/i18nLocaleCompleteness.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import en from '../../src/renderer/i18n/locales/en.json';
|
||||||
|
import de from '../../src/renderer/i18n/locales/de.json';
|
||||||
|
import fr from '../../src/renderer/i18n/locales/fr.json';
|
||||||
|
import itLocale from '../../src/renderer/i18n/locales/it.json';
|
||||||
|
import es from '../../src/renderer/i18n/locales/es.json';
|
||||||
|
|
||||||
|
type LocaleMap = Record<string, string>;
|
||||||
|
|
||||||
|
const locales: Record<string, LocaleMap> = {
|
||||||
|
de: de as LocaleMap,
|
||||||
|
fr: fr as LocaleMap,
|
||||||
|
it: itLocale as LocaleMap,
|
||||||
|
es: es as LocaleMap,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('renderer locale completeness', () => {
|
||||||
|
const englishKeys = Object.keys(en as LocaleMap).sort();
|
||||||
|
|
||||||
|
it('all renderer locales contain exactly the english key set', () => {
|
||||||
|
for (const [locale, messages] of Object.entries(locales)) {
|
||||||
|
const localeKeys = Object.keys(messages).sort();
|
||||||
|
|
||||||
|
expect(localeKeys, `Locale ${locale} is missing or has extra keys`).toEqual(englishKeys);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,12 +40,11 @@ describe('Help menu documentation entry', () => {
|
|||||||
expect(viewGroup?.items.some((item) => item.action === 'toggleFullScreen')).toBe(true);
|
expect(viewGroup?.items.some((item) => item.action === 'toggleFullScreen')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renames generateSitemap menu item to Render Site and assigns Command/Ctrl+R', () => {
|
it('assigns Command/Ctrl+R shortcut for generateSitemap menu item', () => {
|
||||||
const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog');
|
const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog');
|
||||||
const generateSiteItem = blogGroup?.items.find((item) => item.action === 'generateSitemap');
|
const generateSiteItem = blogGroup?.items.find((item) => item.action === 'generateSitemap');
|
||||||
|
|
||||||
expect(generateSiteItem).toBeDefined();
|
expect(generateSiteItem).toBeDefined();
|
||||||
expect(generateSiteItem?.label).toBe('Render Site');
|
|
||||||
expect(generateSiteItem?.accelerator).toBe('CmdOrCtrl+R');
|
expect(generateSiteItem?.accelerator).toBe('CmdOrCtrl+R');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user