feat: i18n support with first translations

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

View File

@@ -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
### Separation of Concerns

View File

@@ -5,6 +5,7 @@ import type { MediaData } from './MediaEngine';
import type { PostData } from './PostEngine';
import { PICO_THEME_NAMES } from '../shared/picoThemes';
import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime';
import { resolveRenderLanguageFromProjectPreferences, translateRender } from '../shared/i18n';
export interface HtmlRewriteContext {
canonicalPostPathBySlug: Map<string, string>;
@@ -87,6 +88,8 @@ export interface SinglePostTemplateContext {
export interface NotFoundTemplateContext {
page_title: string;
language: string;
not_found_message?: string;
not_found_back_label?: string;
pico_stylesheet_href?: string;
html_theme_attribute?: string;
}
@@ -336,7 +339,9 @@ export function renderGalleryMacro(
postId: string,
mediaItems: MediaData[],
linkedMediaIds: Set<string> | null,
renderLanguage: string,
): string {
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
const requestedColumns = parseIntegerParam(params.columns);
const columns = requestedColumns && requestedColumns >= 1 && requestedColumns <= 6 ? requestedColumns : 3;
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>`;
}).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>`;
}
export function renderPhotoArchiveMacro(params: Record<string, string>, mediaItems: MediaData[]): string {
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
export function renderPhotoArchiveMacro(
params: Record<string, string>,
mediaItems: MediaData[],
renderLanguage: string,
): string {
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
const yearParam = parseIntegerParam(params.year);
const monthParam = parseIntegerParam(params.month);
@@ -393,11 +402,12 @@ export function renderPhotoArchiveMacro(params: Record<string, string>, mediaIte
const buckets = buildPhotoArchiveBuckets(renderableMedia, params);
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 monthName = monthNames[bucket.month - 1] || String(bucket.month);
const monthName = translateRender(language, `render.month.${bucket.month}`);
const label = `${monthName} ${bucket.year}`;
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>`;
}
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 heightParam = parseIntegerParam(params.height);
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;
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));
@@ -445,7 +456,8 @@ export function renderTagCloudMacro(params: Record<string, string>, tagUsage: Ta
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 {
@@ -551,33 +563,36 @@ export function renderMacro(
mediaItems: MediaData[],
linkedMediaIds: Set<string> | null,
tagUsage: TagUsageEntry[],
renderLanguage: string,
): string {
const normalizedName = normalizeMacroName(name);
if (normalizedName === 'youtube') {
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
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 '';
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') {
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
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 '';
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') {
return renderGalleryMacro(params, postId, mediaItems, linkedMediaIds);
return renderGalleryMacro(params, postId, mediaItems, linkedMediaIds, renderLanguage);
}
if (normalizedName === 'photo_archive') {
return renderPhotoArchiveMacro(params, mediaItems);
return renderPhotoArchiveMacro(params, mediaItems, renderLanguage);
}
if (normalizedName === 'tag_cloud') {
return renderTagCloudMacro(params, tagUsage);
return renderTagCloudMacro(params, tagUsage, renderLanguage);
}
return '';
@@ -677,9 +692,23 @@ export class PageRenderer {
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 postId = typeof postIdArg === 'string' ? postIdArg : '';
const renderLanguage = typeof renderLanguageArg === 'string' ? renderLanguageArg : 'en';
const rewriteContext: HtmlRewriteContext = {
canonicalPostPathBySlug: recordToMap(canonicalPostsArg),
canonicalMediaPathBySourcePath: recordToMap(canonicalMediaArg),
@@ -703,7 +732,7 @@ export class PageRenderer {
const withMacros = content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => {
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 });
@@ -964,6 +993,10 @@ export class PageRenderer {
}
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,
});
}
}

View File

@@ -6,8 +6,10 @@
<section class="not-found" data-template="not-found">
<article>
<h1>404</h1>
<p>The requested preview page could not be found.</p>
<p><a href="/" role="button">Back to preview home</a></p>
{% assign default_not_found_message = 'render.notFound.message' | i18n: language %}
{% 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>
</section>
</main>

View File

@@ -4,26 +4,23 @@
<body>
<main>
{% 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 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>
{% 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 %}
{% else %}
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
<h1 class="archive-heading">{{ archive_context.name }}</h1>
{% elsif archive_context.kind == 'month' and archive_context.month and archive_context.year %}
{% assign month_index = archive_context.month | minus: 1 %}
{% assign month_name = month_names_de[month_index] %}
<h1 class="archive-heading">Archiv {{ month_name }} {{ archive_context.year }}</h1>
{% assign month_key = 'render.month.' | append: archive_context.month %}
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ month_key | i18n: language }} {{ archive_context.year }}</h1>
{% 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 %}
{% assign day_month_index = archive_context.month | minus: 1 %}
{% assign day_month_name = month_names_de[day_month_index] %}
<h1 class="archive-heading">Archiv {{ archive_context.day }}. {{ day_month_name }} {{ archive_context.year }}</h1>
{% assign day_month_key = 'render.month.' | append: archive_context.month %}
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ archive_context.day }}. {{ day_month_key | i18n: language }} {{ archive_context.year }}</h1>
{% else %}
<h1 class="archive-heading">{{ page_title }}</h1>
{% endif %}
@@ -41,7 +38,7 @@
{% if post.show_title %}
<h2 class="post-title">{{ post.title }}</h2>
{% 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>
{% endfor %}
</div>
@@ -52,7 +49,7 @@
{% if post.show_title %}
<h2 class="post-title">{{ post.title }}</h2>
{% 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>
{% endfor %}
{% endif %}
@@ -64,15 +61,15 @@
</section>
{% 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 %}
<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 %}
<span class="spacer"></span>
{% endif %}
{% 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 %}
<span class="spacer"></span>
{% endif %}

View File

@@ -5,7 +5,7 @@
<main>
<article class="single-post" data-template="single-post">
<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>
</main>
</body>

View File

@@ -222,6 +222,10 @@ export function registerIpcHandlers(): void {
return engine.deleteProjectWithData(id);
});
safeHandle('app:getSystemLanguage', async () => {
return app.getLocale();
});
safeHandle('projects:get', async (_, id: string) => {
const engine = getProjectEngine();
return engine.getProject(id);

View File

@@ -9,6 +9,7 @@ import { getMediaEngine } from './engine/MediaEngine';
import { getPostEngine } from './engine/PostEngine';
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 { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n';
let mainWindow: BrowserWindow | null = null;
let previewServer: PreviewServer | null = null;
@@ -170,6 +171,8 @@ async function startPreviewServerOnAppStart(): Promise<void> {
}
function createApplicationMenu(): Menu {
const systemLocale = typeof app.getLocale === 'function' ? app.getLocale() : 'en';
const uiLanguage = resolveUiLanguageFromSystemLocale(systemLocale);
const commandDefinitions = APP_MENU_GROUPS
.flatMap(group => group.items)
.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 definition = commandDefinitions[action];
if (!definition) {
throw new Error(`Unknown shared menu action: ${action}`);
}
const translatedLabel = getMenuItemLabel(action, definition.label);
if (definition.role) {
return {
label: definition.label,
label: translatedLabel,
role: definition.role,
accelerator: definition.accelerator,
};
}
return {
label: definition.label,
label: translatedLabel,
accelerator: definition.accelerator,
id: definition.id,
enabled: definition.enabled,
@@ -302,23 +315,23 @@ function createApplicationMenu(): Menu {
const template: MenuItemConstructorOptions[] = [
{
label: 'File',
label: getMenuGroupLabel('File'),
submenu: buildSharedGroupMenuItems('File'),
},
{
label: 'Edit',
label: getMenuGroupLabel('Edit'),
submenu: buildSharedGroupMenuItems('Edit'),
},
{
label: 'View',
label: getMenuGroupLabel('View'),
submenu: buildSharedGroupMenuItems('View'),
},
{
label: 'Blog',
label: getMenuGroupLabel('Blog'),
submenu: buildSharedGroupMenuItems('Blog'),
},
{
label: 'Help',
label: getMenuGroupLabel('Help'),
submenu: buildSharedGroupMenuItems('Help'),
},
];

View File

@@ -137,6 +137,7 @@ export const electronAPI: ElectronAPI = {
// App
app: {
getDataPaths: () => ipcRenderer.invoke('app:getDataPaths'),
getSystemLanguage: () => ipcRenderer.invoke('app:getSystemLanguage'),
getTitleBarMetrics: () => ipcRenderer.invoke('app:getTitleBarMetrics'),
openFolder: (folderPath: string) => ipcRenderer.invoke('app:openFolder', folderPath),
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),

View File

@@ -516,6 +516,7 @@ export interface ElectronAPI {
};
app: {
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
getSystemLanguage: () => Promise<string>;
getTitleBarMetrics: () => Promise<{ macosLeftInset: number } | null>;
openFolder: (folderPath: string) => Promise<string>;
showItemInFolder: (itemPath: string) => Promise<void>;

55
src/main/shared/i18n.ts Normal file
View 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;
}

View 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"
}

View 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"
}

View 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"
}

View 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 larticle",
"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 daperçu demandée est introuvable.",
"render.notFound.back": "Retour à laccueil de laperç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"
}

View 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"
}

View File

@@ -61,76 +61,76 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
{
label: 'File',
items: [
{ label: 'New Post', action: 'newPost', accelerator: 'CmdOrCtrl+N' },
{ label: 'Import Media...', action: 'importMedia', accelerator: 'CmdOrCtrl+I' },
{ label: 'menu.item.newPost', action: 'newPost', accelerator: 'CmdOrCtrl+N' },
{ label: 'menu.item.importMedia', action: 'importMedia', accelerator: 'CmdOrCtrl+I' },
{ 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: 'Open in Browser', action: 'openInBrowser' },
{ label: 'menu.item.openInBrowser', action: 'openInBrowser' },
{ 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: 'Quit', action: 'quit', accelerator: 'CmdOrCtrl+Q' },
{ label: 'menu.item.quit', action: 'quit', accelerator: 'CmdOrCtrl+Q' },
],
},
{
label: 'Edit',
items: [
{ label: 'Undo', action: 'undo', accelerator: 'CmdOrCtrl+Z', role: 'undo' },
{ label: 'Redo', action: 'redo', accelerator: 'CmdOrCtrl+Y', role: 'redo' },
{ label: 'menu.item.undo', action: 'undo', accelerator: 'CmdOrCtrl+Z', role: 'undo' },
{ label: 'menu.item.redo', action: 'redo', accelerator: 'CmdOrCtrl+Y', role: 'redo' },
{ label: '', action: 'edit-separator-1', separator: true },
{ label: 'Cut', action: 'cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
{ label: 'Copy', action: 'copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
{ label: 'Paste', action: 'paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
{ label: 'Delete', action: 'delete', role: 'delete' },
{ label: 'menu.item.cut', action: 'cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
{ label: 'menu.item.copy', action: 'copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
{ label: 'menu.item.paste', action: 'paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
{ label: 'menu.item.delete', action: 'delete', role: 'delete' },
{ 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: 'Find', action: 'find', accelerator: 'CmdOrCtrl+F' },
{ label: 'Replace', action: 'replace', accelerator: 'CmdOrCtrl+H' },
{ label: 'menu.item.find', action: 'find', accelerator: 'CmdOrCtrl+F' },
{ label: 'menu.item.replace', action: 'replace', accelerator: 'CmdOrCtrl+H' },
],
},
{
label: 'View',
items: [
{ label: 'Posts', action: 'viewPosts', accelerator: 'CmdOrCtrl+1' },
{ label: 'Media', action: 'viewMedia', accelerator: 'CmdOrCtrl+2' },
{ label: 'Toggle Sidebar', action: 'toggleSidebar', accelerator: 'CmdOrCtrl+B' },
{ label: 'Toggle Panel', action: 'togglePanel', accelerator: 'CmdOrCtrl+J' },
{ label: 'Toggle Developer Tools', action: 'toggleDevTools', accelerator: 'CmdOrCtrl+Shift+I' },
{ label: 'menu.item.viewPosts', action: 'viewPosts', accelerator: 'CmdOrCtrl+1' },
{ label: 'menu.item.viewMedia', action: 'viewMedia', accelerator: 'CmdOrCtrl+2' },
{ label: 'menu.item.toggleSidebar', action: 'toggleSidebar', accelerator: 'CmdOrCtrl+B' },
{ label: 'menu.item.togglePanel', action: 'togglePanel', accelerator: 'CmdOrCtrl+J' },
{ label: 'menu.item.toggleDevTools', action: 'toggleDevTools', accelerator: 'CmdOrCtrl+Shift+I' },
{ label: '', action: 'view-separator-1', separator: true },
{ label: 'Reload', action: 'reload' },
{ label: 'Force Reload', action: 'forceReload' },
{ label: 'menu.item.reload', action: 'reload' },
{ label: 'menu.item.forceReload', action: 'forceReload' },
{ label: '', action: 'view-separator-2', separator: true },
{ label: 'Actual Size', action: 'resetZoom' },
{ label: 'Zoom In', action: 'zoomIn' },
{ label: 'Zoom Out', action: 'zoomOut' },
{ label: 'menu.item.resetZoom', action: 'resetZoom' },
{ label: 'menu.item.zoomIn', action: 'zoomIn' },
{ label: 'menu.item.zoomOut', action: 'zoomOut' },
{ label: '', action: 'view-separator-3', separator: true },
{ label: 'Toggle Full Screen', action: 'toggleFullScreen' },
{ label: 'menu.item.toggleFullScreen', action: 'toggleFullScreen' },
],
},
{
label: 'Blog',
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: '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: 'Rebuild Database from Files', action: 'rebuildDatabase' },
{ label: 'Reindex Search Text', action: 'reindexText' },
{ label: 'menu.item.rebuildDatabase', action: 'rebuildDatabase' },
{ label: 'menu.item.reindexText', action: 'reindexText' },
{ label: '', action: 'blog-separator-3', separator: true },
{ label: 'Metadata Diff Tool', action: 'metadataDiff' },
{ label: 'Render Site', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
{ label: 'menu.item.metadataDiff', action: 'metadataDiff' },
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
],
},
{
label: 'Help',
items: [
{ label: 'About Blogging Desktop Server', action: 'about' },
{ label: 'Open Documentation', action: 'openDocumentation' },
{ label: 'menu.item.about', action: 'about' },
{ label: 'menu.item.openDocumentation', action: 'openDocumentation' },
{ label: '', action: 'help-separator-1', separator: true },
{ label: 'View on GitHub', action: 'viewOnGitHub' },
{ label: 'Report Issue', action: 'reportIssue' },
{ label: 'menu.item.viewOnGitHub', action: 'viewOnGitHub' },
{ label: 'menu.item.reportIssue', action: 'reportIssue' },
],
},
];

View File

@@ -3,9 +3,11 @@ import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer,
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import { loadTabsForProject, saveTabsForProject } from './utils';
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
import { useI18n } from './i18n';
import './App.css';
const App: React.FC = () => {
const { t: tr } = useI18n();
const {
setPosts,
setMedia,
@@ -93,7 +95,7 @@ const App: React.FC = () => {
window.addEventListener('beforeunload', saveTabsOnUnload);
return () => window.removeEventListener('beforeunload', saveTabsOnUnload);
}, []);
}, [tr]);
// Set up event listeners for real-time updates
useEffect(() => {
@@ -166,7 +168,7 @@ const App: React.FC = () => {
window.electronAPI?.on('task:completed', (task: unknown) => {
const t = task as TaskProgress;
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) => {
const t = task as TaskProgress;
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();
} catch (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) {
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(
window.electronAPI?.on('menu:metadataDiff', () => {
// 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();
} catch (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) {
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 importedMedia = data.media.imported;
if (data.success) {
showToast.success(`Import complete: ${importedCount} posts, ${importedMedia} media files`);
showToast.success(tr('app.importComplete', { posts: importedCount, media: importedMedia }));
}
}) || (() => {})
);

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useI18n } from '../../i18n';
import './AISuggestionsModal.css';
export interface AISuggestions {
@@ -21,9 +22,9 @@ interface SuggestionFieldConfig {
}
const SUGGESTION_FIELDS: SuggestionFieldConfig[] = [
{ key: 'title', label: 'Title' },
{ key: 'alt', label: 'Alt Text' },
{ key: 'caption', label: 'Caption' },
{ key: 'title', label: 'aiSuggestions.titleField' },
{ key: 'alt', label: 'aiSuggestions.altField' },
{ key: 'caption', label: 'aiSuggestions.captionField' },
];
interface AISuggestionsModalProps {
@@ -45,6 +46,7 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
onConfirm,
onClose,
}) => {
const { t: tr } = useI18n();
// Checkbox state - initialized based on whether current values are empty
const [useTitle, setUseTitle] = useState(false);
const [useAlt, setUseAlt] = useState(false);
@@ -107,15 +109,15 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
<div className="ai-suggestion-label">
{field.label}
{currentValue && (
<span className="ai-suggestion-has-value" title="This field already has a value">
(has existing value)
<span className="ai-suggestion-has-value" title={tr('aiSuggestions.hasExisting')}>
{tr('aiSuggestions.hasExisting')}
</span>
)}
</div>
<div className="ai-suggestion-value">{suggestedValue}</div>
{currentValue && (
<div className="ai-suggestion-current">
Current: <em>{currentValue}</em>
{tr('aiSuggestions.current')}: <em>{currentValue}</em>
</div>
)}
</div>
@@ -127,9 +129,9 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
<div className="ai-suggestions-modal-backdrop" onClick={handleBackdropClick}>
<div className="ai-suggestions-modal">
<div className="ai-suggestions-modal-header">
<h2>AI Image Analysis</h2>
<h2>{tr('aiSuggestions.title')}</h2>
{!isLoading && (
<button className="ai-suggestions-modal-close" onClick={onClose} title="Close">
<button className="ai-suggestions-modal-close" onClick={onClose} title={tr('aiSuggestions.close')}>
</button>
)}
@@ -139,7 +141,7 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
{isLoading && (
<div className="ai-suggestions-loading">
<div className="ai-suggestions-spinner"></div>
<p>Analyzing image...</p>
<p>{tr('aiSuggestions.analyzing')}</p>
</div>
)}
@@ -153,15 +155,15 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
{!isLoading && !error && hasAnySuggestion && (
<div className="ai-suggestions-list">
<p className="ai-suggestions-intro">
Select which AI-generated values to apply. Existing values are preserved by default.
{tr('aiSuggestions.intro')}
</p>
{SUGGESTION_FIELDS.map(renderSuggestionField)}
{SUGGESTION_FIELDS.map((field) => renderSuggestionField({ ...field, label: tr(field.label) }))}
</div>
)}
{!isLoading && !error && !hasAnySuggestion && suggestions && (
<div className="ai-suggestions-empty">
No suggestions were generated for this image.
{tr('aiSuggestions.empty')}
</div>
)}
</div>
@@ -169,12 +171,12 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
<div className="ai-suggestions-modal-footer">
{isLoading ? (
<button className="button-cancel" disabled>
Please wait...
{tr('aiSuggestions.wait')}
</button>
) : (
<>
<button className="button-cancel" onClick={onClose}>
Cancel
{tr('common.cancel')}
</button>
{hasAnySuggestion && (
<button
@@ -182,7 +184,7 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
onClick={handleConfirm}
disabled={!hasAnySelected}
>
Apply Selected
{tr('aiSuggestions.applySelected')}
</button>
)}
</>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { useAppStore } from '../../store';
import { useI18n } from '../../i18n';
import './ActivityBar.css';
// Simple SVG icons
@@ -56,6 +57,7 @@ const GitIcon = () => (
);
export const ActivityBar: React.FC = () => {
const { t } = useI18n();
const { activeView, setActiveView, sidebarVisible, toggleSidebar, openTab, tabs, activeTabId } = useAppStore();
// Check if settings tab is currently active
@@ -127,42 +129,42 @@ export const ActivityBar: React.FC = () => {
<button
className={`activity-bar-item ${activeView === 'posts' && sidebarVisible ? 'active' : ''}`}
onClick={() => handleViewClick('posts')}
title="Posts (click again to toggle sidebar)"
title={`${t('activity.posts')} ${t('activity.toggleHint')}`}
>
<PostsIcon />
</button>
<button
className={`activity-bar-item ${activeView === 'pages' && sidebarVisible ? 'active' : ''}`}
onClick={() => handleViewClick('pages')}
title="Pages (click again to toggle sidebar)"
title={`${t('activity.pages')} ${t('activity.toggleHint')}`}
>
<PagesIcon />
</button>
<button
className={`activity-bar-item ${activeView === 'media' && sidebarVisible ? 'active' : ''}`}
onClick={() => handleViewClick('media')}
title="Media (click again to toggle sidebar)"
title={`${t('activity.media')} ${t('activity.toggleHint')}`}
>
<MediaIcon />
</button>
<button
className={`activity-bar-item ${isTagsTabActive ? 'active' : ''}`}
onClick={handleTagsClick}
title="Tags"
title={t('activity.tags')}
>
<TagsIcon />
</button>
<button
className={`activity-bar-item ${isChatActive ? 'active' : ''}`}
onClick={() => handleViewClick('chat')}
title="AI Assistant (click again to toggle sidebar)"
title={`${t('activity.aiAssistant')} ${t('activity.toggleHint')}`}
>
<ChatIcon />
</button>
<button
className={`activity-bar-item ${isImportActive ? 'active' : ''}`}
onClick={handleImportClick}
title="Import (click again to toggle sidebar)"
title={`${t('activity.import')} ${t('activity.toggleHint')}`}
>
<ImportIcon />
</button>
@@ -172,14 +174,14 @@ export const ActivityBar: React.FC = () => {
<button
className={`activity-bar-item ${isGitActive ? 'active' : ''}`}
onClick={() => handleViewClick('git')}
title="Source Control (click again to toggle sidebar)"
title={`${t('activity.sourceControl')} ${t('activity.toggleHint')}`}
>
<GitIcon />
</button>
<button
className={`activity-bar-item ${isSettingsActive ? 'active' : ''}`}
onClick={handleSettingsClick}
title="Settings (click again to toggle sidebar)"
title={`${t('common.settings')} ${t('activity.toggleHint')}`}
>
<SettingsIcon />
</button>

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import Markdown from 'marked-react';
import type { ChatMessage, ChatConversation, ChatModel } from '../../types/electron';
import { useI18n } from '../../i18n';
import './ChatPanel.css';
interface ChatPanelProps {
@@ -8,6 +9,7 @@ interface ChatPanelProps {
}
export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
const { t: tr } = useI18n();
const [conversation, setConversation] = useState<ChatConversation | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState('');
@@ -126,10 +128,10 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
setApiKeyInput('');
loadData();
} else {
setApiKeyError('Invalid API key. Please check and try again.');
setApiKeyError(tr('chat.apiKeyInvalid'));
}
} catch {
setApiKeyError('Failed to validate API key.');
setApiKeyError(tr('chat.apiKeyValidationFailed'));
} finally {
setIsValidating(false);
}
@@ -184,7 +186,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
id: `error-${Date.now()}`,
conversationId,
role: 'assistant',
content: `Error: ${result.error || 'Failed to get a response. Please try again.'}`,
content: tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }),
createdAt: new Date().toISOString()
};
setMessages(prev => [...prev, errorMessage]);
@@ -195,7 +197,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
id: `empty-${Date.now()}`,
conversationId,
role: 'assistant',
content: 'The model returned an empty response. Try a different model or rephrase your question.',
content: tr('chat.errorEmptyResponse'),
createdAt: new Date().toISOString()
};
setMessages(prev => [...prev, noContentMessage]);
@@ -206,7 +208,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
id: `error-${Date.now()}`,
conversationId,
role: 'assistant',
content: 'Sorry, an error occurred while processing your message.',
content: tr('chat.errorGeneric'),
createdAt: new Date().toISOString()
};
setMessages(prev => [...prev, errorMessage]);
@@ -241,7 +243,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
id: `partial-${Date.now()}`,
conversationId,
role: 'assistant',
content: partialContent + '\n\n*(cancelled)*',
content: `${partialContent}\n\n*(${tr('chat.cancelledSuffix')})*`,
createdAt: new Date().toISOString()
};
setMessages(prev => [...prev, partialMessage]);
@@ -323,7 +325,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
<div className="chat-message-content">
<div className="chat-message-header">
<span className="chat-message-role">
{msg.role === 'user' ? 'You' : 'Assistant'}
{msg.role === 'user' ? tr('chat.role.you') : tr('chat.role.assistant')}
</span>
</div>
{storedToolCalls.length > 0 && (
@@ -361,13 +363,13 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
return (
<div className="chat-panel">
<div className="chat-panel-header">
<div className="chat-panel-title">AI Chat Setup</div>
<div className="chat-panel-title">{tr('chat.setupTitle')}</div>
</div>
<div className="chat-messages">
<div className="chat-welcome">
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
<h2>OpenCode Zen API Key Required</h2>
<p>Enter your OpenCode API key to enable AI chat.</p>
<h2>{tr('chat.apiKeyRequiredTitle')}</h2>
<p>{tr('chat.apiKeyRequiredDescription')}</p>
<div className="api-key-form">
<input
type="password"
@@ -375,7 +377,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()}
placeholder="Enter your API key..."
placeholder={tr('chat.apiKeyPlaceholder')}
disabled={isValidating}
/>
<button
@@ -383,7 +385,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
onClick={handleApiKeySubmit}
disabled={!apiKeyInput.trim() || isValidating}
>
{isValidating ? 'Validating...' : 'Save Key'}
{isValidating ? tr('chat.apiKeyValidating') : tr('chat.apiKeySave')}
</button>
{apiKeyError && <div className="api-key-error">{apiKeyError}</div>}
</div>
@@ -397,7 +399,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
<div className="chat-panel">
<div className="chat-panel-header">
<div className="chat-panel-title">
{conversation?.title || 'New Chat'}
{conversation?.title || tr('chat.newChat')}
</div>
<div className="chat-panel-model">
<button
@@ -427,14 +429,14 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
{messages.length === 0 && !isStreaming && (
<div className="chat-welcome">
<div className="chat-welcome-icon">{'\u{1F916}'}</div>
<h2>Welcome to the AI Assistant</h2>
<p>I can help you manage your posts and media. Try asking me to:</p>
<h2>{tr('chat.welcomeTitle')}</h2>
<p>{tr('chat.welcomeDescription')}</p>
<ul>
<li>Search for posts about a specific topic</li>
<li>Get details about a specific post</li>
<li>List all tags or categories in your blog</li>
<li>Update metadata for posts or media</li>
<li>List all images in your media library</li>
<li>{tr('chat.welcomeTipSearch')}</li>
<li>{tr('chat.welcomeTipDetails')}</li>
<li>{tr('chat.welcomeTipTags')}</li>
<li>{tr('chat.welcomeTipMetadata')}</li>
<li>{tr('chat.welcomeTipImages')}</li>
</ul>
</div>
)}
@@ -446,7 +448,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
<div className="chat-message-avatar">{'\u{1F916}'}</div>
<div className="chat-message-content">
<div className="chat-message-header">
<span className="chat-message-role">Assistant</span>
<span className="chat-message-role">{tr('chat.role.assistant')}</span>
<span className="streaming-indicator">{'\u25CF'}</span>
</div>
{renderToolMarkers(toolEvents)}
@@ -476,7 +478,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
<div className="chat-input-container">
{isStreaming && (
<button className="chat-abort-button" onClick={handleAbort}>
{'\u25FC'} Stop
{'\u25FC'} {tr('chat.stop')}
</button>
)}
<div className="chat-input-wrapper">
@@ -491,7 +493,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
}}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
placeholder={tr('chat.inputPlaceholder')}
rows={1}
disabled={isStreaming}
/>

View File

@@ -1,4 +1,5 @@
import React, { useCallback } from 'react';
import { useI18n } from '../../i18n';
import './ConfirmDeleteModal.css';
export interface DeleteReference {
@@ -20,6 +21,7 @@ interface ConfirmDeleteModalProps {
}
export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details, onClose }) => {
const { t: tr } = useI18n();
if (!details) return null;
const handleConfirm = useCallback(async () => {
@@ -39,14 +41,14 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
<div className="confirm-delete-modal-backdrop" onClick={handleBackdropClick}>
<div className="confirm-delete-modal">
<div className="confirm-delete-modal-header">
<h2>Confirm Deletion</h2>
<button className="confirm-delete-modal-close" onClick={onClose} title="Close">
<h2>{tr('confirmDelete.title')}</h2>
<button className="confirm-delete-modal-close" onClick={onClose} title={tr('aiSuggestions.close')}>
</button>
</div>
<div className="confirm-delete-modal-body">
<div className="confirm-delete-message">
Are you sure you want to delete {details.itemType === 'post' ? 'the post' : 'the media file'}{' '}
{details.itemType === 'post' ? tr('confirmDelete.promptPost') : tr('confirmDelete.promptMedia')}{' '}
<strong>{details.itemTitle}</strong>?
</div>
@@ -54,7 +56,7 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
<div className="confirm-delete-warning">
<div className="warning-icon"></div>
<div className="warning-content">
<strong>Warning:</strong> This {details.itemType} is referenced by the following items:
<strong>{tr('confirmDelete.warning')}</strong> {tr('confirmDelete.referencedBy', { itemType: tr(`confirmDelete.itemType.${details.itemType}`) })}
<ul className="reference-list">
{details.references.map((ref) => (
<li key={ref.id}>
@@ -66,7 +68,7 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
))}
</ul>
<p className="warning-note">
Deleting this {details.itemType} will remove all these references.
{tr('confirmDelete.note', { itemType: tr(`confirmDelete.itemType.${details.itemType}`) })}
</p>
</div>
</div>
@@ -74,10 +76,10 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
</div>
<div className="confirm-delete-modal-footer">
<button className="button-cancel" onClick={onClose}>
Cancel
{tr('confirmDelete.cancel')}
</button>
<button className="button-delete" onClick={handleConfirm}>
Delete {details.itemType === 'post' ? 'Post' : 'Media'}
{details.itemType === 'post' ? tr('confirmDelete.deletePost') : tr('confirmDelete.deleteMedia')}
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { showToast } from '../Toast';
import { useI18n } from '../../i18n';
import './CredentialsPanel.css';
interface Credentials {
@@ -12,6 +13,7 @@ interface Credentials {
}
export const CredentialsPanel: React.FC = () => {
const { t: tr } = useI18n();
const [credentials, setCredentials] = useState<Credentials>({
ftpHost: '',
ftpUser: '',
@@ -32,7 +34,7 @@ export const CredentialsPanel: React.FC = () => {
setCredentials(JSON.parse(savedCreds));
}
} catch (error) {
console.error('Failed to load credentials:', error);
console.error(tr('credentials.error.load'), error);
}
};
loadCredentials();
@@ -43,10 +45,10 @@ export const CredentialsPanel: React.FC = () => {
// Save to localStorage (in production, use secure storage)
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
showToast.success('Credentials saved');
showToast.success(tr('credentials.toast.saved'));
} catch (error) {
console.error('Failed to save credentials:', error);
showToast.error('Failed to save credentials');
console.error(tr('credentials.error.save'), error);
showToast.error(tr('credentials.toast.saveFailed'));
}
};
@@ -68,14 +70,14 @@ export const CredentialsPanel: React.FC = () => {
};
const handleTestConnection = async (type: 'ftp' | 'ssh') => {
showToast.loading(`Testing ${type.toUpperCase()} connection...`);
showToast.loading(tr('credentials.toast.testing', { type: type.toUpperCase() }));
// Simulate connection test
await new Promise(resolve => setTimeout(resolve, 1500));
// In a real implementation, this would test the actual connection
showToast.dismiss();
showToast.error('Connection failed - check credentials');
showToast.error(tr('credentials.toast.connectionFailed'));
};
return (
@@ -85,13 +87,13 @@ export const CredentialsPanel: React.FC = () => {
className={activeTab === 'ftp' ? 'active' : ''}
onClick={() => setActiveTab('ftp')}
>
FTP
{tr('credentials.tab.ftp')}
</button>
<button
className={activeTab === 'ssh' ? 'active' : ''}
onClick={() => setActiveTab('ssh')}
>
SSH
{tr('credentials.tab.ssh')}
</button>
</div>
@@ -99,49 +101,49 @@ export const CredentialsPanel: React.FC = () => {
{activeTab === 'ftp' && (
<div className="credentials-form">
<div className="credentials-header">
<h4>FTP Publishing</h4>
<h4>{tr('credentials.ftp.title')}</h4>
<p className="text-muted">
Configure FTP for publishing your blog to a web server.
{tr('credentials.ftp.description')}
</p>
</div>
<div className="credentials-field">
<label>Host</label>
<label>{tr('credentials.field.host')}</label>
<input
type="text"
placeholder="ftp.example.com"
placeholder={tr('credentials.ftp.placeholder.host')}
value={credentials.ftpHost}
onChange={(e) => setCredentials({ ...credentials, ftpHost: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>Username</label>
<label>{tr('credentials.field.username')}</label>
<input
type="text"
placeholder="ftp-user"
placeholder={tr('credentials.ftp.placeholder.username')}
value={credentials.ftpUser}
onChange={(e) => setCredentials({ ...credentials, ftpUser: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>Password</label>
<label>{tr('credentials.field.password')}</label>
<input
type={showTokens ? 'text' : 'password'}
placeholder="Password"
placeholder={tr('credentials.ftp.placeholder.password')}
value={credentials.ftpPassword}
onChange={(e) => setCredentials({ ...credentials, ftpPassword: e.target.value })}
/>
</div>
<div className="credentials-actions">
<button onClick={handleSave}>Save</button>
<button onClick={handleSave}>{tr('common.save')}</button>
<button className="secondary" onClick={() => handleTestConnection('ftp')}>
Test Connection
{tr('credentials.action.testConnection')}
</button>
<button className="secondary danger" onClick={() => handleClear('ftp')}>
Clear
{tr('common.clear')}
</button>
</div>
</div>
@@ -150,49 +152,49 @@ export const CredentialsPanel: React.FC = () => {
{activeTab === 'ssh' && (
<div className="credentials-form">
<div className="credentials-header">
<h4>SSH Publishing</h4>
<h4>{tr('credentials.ssh.title')}</h4>
<p className="text-muted">
Configure SSH for secure publishing to your server.
{tr('credentials.ssh.description')}
</p>
</div>
<div className="credentials-field">
<label>Host</label>
<label>{tr('credentials.field.host')}</label>
<input
type="text"
placeholder="server.example.com"
placeholder={tr('credentials.ssh.placeholder.host')}
value={credentials.sshHost}
onChange={(e) => setCredentials({ ...credentials, sshHost: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>Username</label>
<label>{tr('credentials.field.username')}</label>
<input
type="text"
placeholder="ssh-user"
placeholder={tr('credentials.ssh.placeholder.username')}
value={credentials.sshUser}
onChange={(e) => setCredentials({ ...credentials, sshUser: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>SSH Key Path</label>
<label>{tr('credentials.field.sshKeyPath')}</label>
<input
type="text"
placeholder="~/.ssh/id_rsa"
placeholder={tr('credentials.ssh.placeholder.keyPath')}
value={credentials.sshKeyPath}
onChange={(e) => setCredentials({ ...credentials, sshKeyPath: e.target.value })}
/>
</div>
<div className="credentials-actions">
<button onClick={handleSave}>Save</button>
<button onClick={handleSave}>{tr('common.save')}</button>
<button className="secondary" onClick={() => handleTestConnection('ssh')}>
Test Connection
{tr('credentials.action.testConnection')}
</button>
<button className="secondary danger" onClick={() => handleClear('ssh')}>
Clear
{tr('common.clear')}
</button>
</div>
</div>

View File

@@ -2,10 +2,12 @@ import React, { useEffect } from 'react';
import Markdown from 'marked-react';
import documentationContent from '../../../../DOCUMENTATION.md?raw';
import { useAppStore } from '../../store';
import { useI18n } from '../../i18n';
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme';
import './DocumentationView.css';
export const DocumentationView: React.FC = () => {
const { t: tr } = useI18n();
const { picoTheme } = useAppStore();
const resolvedTheme = getRendererPicoTheme(picoTheme);
@@ -18,8 +20,8 @@ export const DocumentationView: React.FC = () => {
return (
<div className="documentation-view">
<div className="documentation-header">
<h1>Documentation</h1>
<p>User guide for this installed bDS version.</p>
<h1>{tr('docs.title')}</h1>
<p>{tr('docs.subtitle')}</p>
</div>
<main className="documentation-scroll">
<div className="documentation-content markdown-body pico" data-theme="auto" data-pico-theme={resolvedTheme}>

View File

@@ -1,4 +1,5 @@
import React, { useCallback } from 'react';
import { useI18n } from '../../i18n';
import './ErrorModal.css';
export interface ErrorDetails {
@@ -13,16 +14,17 @@ interface ErrorModalProps {
}
export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
const { t: tr } = useI18n();
if (!error) return null;
const handleCopyStack = useCallback(async () => {
const textToCopy = `${error.title || 'Error'}\n${error.message}\n\nStack Trace:\n${error.stack || 'No stack trace available'}`;
const textToCopy = `${error.title || tr('errorModal.error')}\n${error.message}\n\n${tr('errorModal.stackTrace')}:\n${error.stack || tr('errorModal.noStack')}`;
try {
await navigator.clipboard.writeText(textToCopy);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
}
}, [error]);
}, [error, tr]);
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
@@ -34,8 +36,8 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
<div className="error-modal-backdrop" onClick={handleBackdropClick}>
<div className="error-modal">
<div className="error-modal-header">
<h2>{error.title || 'Error'}</h2>
<button className="error-modal-close" onClick={onClose} title="Close">
<h2>{error.title || tr('errorModal.error')}</h2>
<button className="error-modal-close" onClick={onClose} title={tr('aiSuggestions.close')}>
</button>
</div>
@@ -44,9 +46,9 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
{error.stack && (
<div className="error-stack-section">
<div className="error-stack-header">
<span>Stack Trace</span>
<button className="copy-button" onClick={handleCopyStack} title="Copy to clipboard">
📋 Copy
<span>{tr('errorModal.stackTrace')}</span>
<button className="copy-button" onClick={handleCopyStack} title={tr('errorModal.copyClipboard')}>
📋 {tr('errorModal.copy')}
</button>
</div>
<pre className="error-stack">{error.stack}</pre>
@@ -54,7 +56,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
)}
</div>
<div className="error-modal-footer">
<button onClick={onClose}>Close</button>
<button onClick={onClose}>{tr('aiSuggestions.close')}</button>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { DiffEditor } from '@monaco-editor/react';
import { useAppStore } from '../../store';
import { useI18n } from '../../i18n';
import './GitDiffView.css';
interface CommitFileDiff {
@@ -47,6 +48,7 @@ function toModelPath(filePath: string, side: 'original' | 'modified', scope: str
}
export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
const { t: tr } = useI18n();
const { activeProject, gitDiffPreferences } = useAppStore();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -94,7 +96,7 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
try {
if (!activeProject) {
setError('No active project selected.');
setError(tr('gitDiff.noProject'));
return;
}
@@ -103,7 +105,7 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
: await window.electronAPI.app.getDefaultProjectPath(activeProject.id);
if (!projectPath) {
setError('Unable to resolve project path.');
setError(tr('gitDiff.noProjectPath'));
return;
}
@@ -129,20 +131,23 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
setModified(diff.modified || '');
}
} catch {
setError('Failed to load diff.');
setError(tr('gitDiff.loadFailed'));
} finally {
setLoading(false);
}
};
void loadDiff();
}, [activeProject, filePath, isCommitDiff, commitHash]);
}, [activeProject, filePath, isCommitDiff, commitHash, tr]);
const headerTarget = isCommitDiff ? `Commit ${commitHash}` : filePath;
const headerLabel = tr('gitDiff.header', { target: headerTarget });
if (loading) {
return (
<div className="git-diff-view">
<div className="git-diff-header">Diff: {isCommitDiff ? `Commit ${commitHash}` : filePath}</div>
<div className="git-diff-message">Loading diff...</div>
<div className="git-diff-header">{headerLabel}</div>
<div className="git-diff-message">{tr('gitDiff.loading')}</div>
</div>
);
}
@@ -150,7 +155,7 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
if (error) {
return (
<div className="git-diff-view">
<div className="git-diff-header">Diff: {isCommitDiff ? `Commit ${commitHash}` : filePath}</div>
<div className="git-diff-header">{headerLabel}</div>
<div className="git-diff-error">{error}</div>
</div>
);
@@ -158,27 +163,27 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
return (
<div className="git-diff-view">
<div className="git-diff-header">Diff: {isCommitDiff ? `Commit ${commitHash}` : filePath}</div>
<div className="git-diff-header">{headerLabel}</div>
{isCommitDiff && commitFiles.length > 0 && (
<div className="git-diff-commit-nav">
<label htmlFor="git-diff-commit-files" className="git-diff-commit-label">
Changed files
{tr('gitDiff.changedFiles')}
</label>
<button
type="button"
className="git-diff-commit-button"
onClick={selectPreviousCommitFile}
disabled={!canSelectPreviousFile}
aria-label="Previous file"
aria-label={tr('gitDiff.previousFile')}
>
Previous
{tr('gitDiff.previousFile')}
</button>
<select
id="git-diff-commit-files"
className="git-diff-commit-select"
value={selectedCommitFilePath}
onChange={(event) => setSelectedCommitFilePath(event.target.value)}
aria-label="Changed files"
aria-label={tr('gitDiff.changedFiles')}
>
{commitFiles.map((entry) => (
<option key={entry.filePath} value={entry.filePath}>
@@ -191,9 +196,9 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
className="git-diff-commit-button"
onClick={selectNextCommitFile}
disabled={!canSelectNextFile}
aria-label="Next file"
aria-label={tr('gitDiff.nextFile')}
>
Next
{tr('gitDiff.nextFile')}
</button>
</div>
)}

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAppStore } from '../../store';
import { useI18n } from '../../i18n';
import type { GitInitProgress, GitHistoryEntry, GitRemoteStateDto } from '../../../main/shared/electronApi';
import './GitSidebar.css';
import '../Sidebar/Sidebar.css';
@@ -27,6 +28,7 @@ const mergeStatusFilesIncremental = (
};
export const GitSidebar: React.FC = () => {
const { t: tr } = useI18n();
const { activeProject, openTab, tabs, closeTab } = useAppStore();
const [projectPath, setProjectPath] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -98,7 +100,7 @@ export const GitSidebar: React.FC = () => {
if (fetchFirst) {
const fetchResult = await window.electronAPI.git.fetch(targetProjectPath);
if (!fetchResult.success) {
const message = fetchResult.error || 'Failed to fetch remote updates.';
const message = fetchResult.error || tr('gitSidebar.error.fetchRemoteUpdates');
setRemoteStateError(message);
if (!background) {
setError(message);
@@ -111,7 +113,7 @@ export const GitSidebar: React.FC = () => {
setRemoteState(nextRemoteState);
setRemoteStateError(null);
} catch {
const message = 'Unable to refresh remote tracking state.';
const message = tr('gitSidebar.error.refreshRemoteState');
setRemoteStateError(message);
if (!background) {
setError(message);
@@ -120,7 +122,7 @@ export const GitSidebar: React.FC = () => {
remoteRefreshInFlightRef.current = false;
}
},
[],
[tr],
);
const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`;
@@ -128,28 +130,28 @@ export const GitSidebar: React.FC = () => {
const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'prune-lfs' | 'commit'): string => {
if (action === 'push') {
return 'Pushing commits to remote... this can take a while for large uploads.';
return tr('gitSidebar.progress.pushingRemote');
}
if (action === 'fetch') {
return 'Fetching remote updates...';
return tr('gitSidebar.progress.fetching');
}
if (action === 'pull') {
return 'Pulling latest changes...';
return tr('gitSidebar.progress.pulling');
}
if (action === 'prune-lfs') {
return 'Pruning local Git LFS cache...';
return tr('gitSidebar.progress.pruningLfs');
}
return 'Creating commit...';
return tr('gitSidebar.progress.committing');
};
const getHistoryStatusLabel = (status: GitHistoryEntry['syncStatus']): string => {
if (status === 'local-only') {
return 'Local only';
return tr('gitSidebar.history.localOnly');
}
if (status === 'remote-only') {
return 'Remote only';
return tr('gitSidebar.history.remoteOnly');
}
return 'Synced';
return tr('gitSidebar.history.synced');
};
const openDiffTab = useCallback(
@@ -194,7 +196,7 @@ export const GitSidebar: React.FC = () => {
try {
const availability = await window.electronAPI.git.checkAvailability();
if (!availability.gitFound) {
setError('Git executable not found. Please install Git and restart the app.');
setError(tr('gitSidebar.error.gitMissing'));
setIsRepo(false);
return;
}
@@ -203,7 +205,7 @@ export const GitSidebar: React.FC = () => {
setProjectPath(resolvedProjectPath);
if (!resolvedProjectPath) {
setError('No active project selected.');
setError(tr('gitSidebar.error.noActiveProject'));
setIsRepo(false);
return;
}
@@ -228,7 +230,7 @@ export const GitSidebar: React.FC = () => {
setRemoteStateError(null);
}
} catch {
setError('Unable to load repository status.');
setError(tr('gitSidebar.error.loadRepoStatus'));
setIsRepo(false);
setHasRemote(false);
setStatusFiles([]);
@@ -238,7 +240,7 @@ export const GitSidebar: React.FC = () => {
} finally {
setLoading(false);
}
}, [refreshRemoteState, refreshRepoDetails, resolveProjectPath]);
}, [refreshRemoteState, refreshRepoDetails, resolveProjectPath, tr]);
useEffect(() => {
void loadRepoState();
@@ -297,7 +299,7 @@ export const GitSidebar: React.FC = () => {
setInitProgress({
phase: 'initializing-repo',
progress: 0,
message: 'Preparing repository initialization...',
message: tr('gitSidebar.progress.preparingInit'),
});
try {
@@ -306,13 +308,13 @@ export const GitSidebar: React.FC = () => {
? await window.electronAPI.git.init(projectPath, normalizedRemoteUrl)
: await window.electronAPI.git.init(projectPath);
if (!result.success) {
setError(result.error || 'Failed to initialize git repository.');
setError(result.error || tr('gitSidebar.error.initFailed'));
return;
}
await loadRepoState();
} catch {
setError('Failed to initialize git repository.');
setError(tr('gitSidebar.error.initFailed'));
} finally {
setInitializing(false);
}
@@ -325,7 +327,7 @@ export const GitSidebar: React.FC = () => {
const effectiveProjectPath = projectPath ?? (await resolveProjectPath());
if (!effectiveProjectPath) {
setError('No active project selected.');
setError(tr('gitSidebar.error.noActiveProject'));
return;
}
if (!projectPath) {
@@ -349,13 +351,13 @@ export const GitSidebar: React.FC = () => {
recentCommitsToKeep: 2,
});
if (!result.success) {
setError(result.error || `Failed to ${action}.`);
setError(result.error || tr('gitSidebar.error.actionFailed', { action }));
setErrorGuidance('guidance' in result ? result.guidance || [] : []);
return;
}
await loadRepoState();
} catch {
setError(`Failed to ${action}.`);
setError(tr('gitSidebar.error.actionFailed', { action }));
} finally {
setActionLoading(null);
}
@@ -368,7 +370,7 @@ export const GitSidebar: React.FC = () => {
const effectiveProjectPath = projectPath ?? (await resolveProjectPath());
if (!effectiveProjectPath) {
setError('No active project selected.');
setError(tr('gitSidebar.error.noActiveProject'));
return;
}
if (!projectPath) {
@@ -382,7 +384,7 @@ export const GitSidebar: React.FC = () => {
const messageToCommit = commitMessageInputRef.current?.value ?? commitMessage;
const result = await window.electronAPI.git.commitAll(effectiveProjectPath, messageToCommit);
if (!result.success) {
setError(result.error || 'Failed to commit changes.');
setError(result.error || tr('gitSidebar.error.commitFailed'));
setErrorGuidance(result.guidance || []);
return;
}
@@ -394,7 +396,7 @@ export const GitSidebar: React.FC = () => {
setCommitMessage('');
await loadRepoState();
} catch {
setError('Failed to commit changes.');
setError(tr('gitSidebar.error.commitFailed'));
} finally {
setActionLoading(null);
}
@@ -403,8 +405,8 @@ export const GitSidebar: React.FC = () => {
if (loading) {
return (
<div className="git-sidebar">
<div className="git-sidebar-header">SOURCE CONTROL</div>
<div className="git-sidebar-empty">Loading...</div>
<div className="git-sidebar-header">{tr('gitSidebar.header')}</div>
<div className="git-sidebar-empty">{tr('gitSidebar.loading')}</div>
</div>
);
}
@@ -417,7 +419,7 @@ export const GitSidebar: React.FC = () => {
onClick={() => setIsTranscriptExpanded((previous) => !previous)}
aria-expanded={isTranscriptExpanded}
>
Initialization transcript
{tr('gitSidebar.init.transcript')}
</button>
{isTranscriptExpanded && (
<ul className="git-sidebar-transcript-list">
@@ -435,16 +437,16 @@ export const GitSidebar: React.FC = () => {
if (isRepo) {
return (
<div className="git-sidebar">
<div className="git-sidebar-header">SOURCE CONTROL</div>
<div className="git-sidebar-header">{tr('gitSidebar.header')}</div>
<div className="git-sidebar-content">
<div className="git-sidebar-actions" role="group" aria-label="Repository actions">
<div className="git-sidebar-actions" role="group" aria-label={tr('gitSidebar.aria.repoActions')}>
<button
type="button"
className="git-sidebar-button"
onClick={() => handleRepoAction('fetch')}
disabled={actionLoading !== null}
>
{actionLoading === 'fetch' ? 'Fetching...' : 'Fetch'}
{actionLoading === 'fetch' ? tr('gitSidebar.action.fetching') : tr('gitSidebar.action.fetch')}
</button>
<button
type="button"
@@ -452,7 +454,7 @@ export const GitSidebar: React.FC = () => {
onClick={() => handleRepoAction('pull')}
disabled={actionLoading !== null}
>
{actionLoading === 'pull' ? 'Pulling...' : 'Pull'}
{actionLoading === 'pull' ? tr('gitSidebar.action.pulling') : tr('gitSidebar.action.pull')}
</button>
<button
type="button"
@@ -460,7 +462,7 @@ export const GitSidebar: React.FC = () => {
onClick={() => handleRepoAction('push')}
disabled={actionLoading !== null}
>
{actionLoading === 'push' ? 'Pushing...' : 'Push'}
{actionLoading === 'push' ? tr('gitSidebar.action.pushing') : tr('gitSidebar.action.push')}
</button>
<button
type="button"
@@ -468,7 +470,7 @@ export const GitSidebar: React.FC = () => {
onClick={() => handleRepoAction('prune-lfs')}
disabled={actionLoading !== null}
>
{actionLoading === 'prune-lfs' ? 'Pruning...' : 'Prune LFS'}
{actionLoading === 'prune-lfs' ? tr('gitSidebar.action.pruning') : tr('gitSidebar.action.pruneLfs')}
</button>
</div>
{actionLoading && (
@@ -478,14 +480,14 @@ export const GitSidebar: React.FC = () => {
)}
<div className="git-sidebar-section">
<div className="sidebar-section-title">Open Changes ({statusFiles.length})</div>
<div className="sidebar-section-title">{tr('gitSidebar.openChanges', { count: statusFiles.length })}</div>
<div className="git-sidebar-commit-row">
<input
ref={commitMessageInputRef}
className="git-sidebar-input"
type="text"
placeholder="Commit message"
placeholder={tr('gitSidebar.placeholder.commitMessage')}
value={commitMessage}
onChange={(event) => setCommitMessage(event.target.value)}
disabled={actionLoading !== null}
@@ -496,16 +498,16 @@ export const GitSidebar: React.FC = () => {
onClick={handleCommit}
disabled={actionLoading !== null}
>
{actionLoading === 'commit' ? 'Committing...' : 'Commit'}
{actionLoading === 'commit' ? tr('gitSidebar.action.committing') : tr('gitSidebar.action.commit')}
</button>
</div>
{statusLoading ? (
<div className="git-sidebar-empty-state">Loading changes...</div>
<div className="git-sidebar-empty-state">{tr('gitSidebar.loadingChanges')}</div>
) : statusFiles.length === 0 ? (
<div className="git-sidebar-empty-state">No changes</div>
<div className="git-sidebar-empty-state">{tr('gitSidebar.noChanges')}</div>
) : (
<div className="git-sidebar-file-list" role="list" aria-label="Open Changes">
<div className="git-sidebar-file-list" role="list" aria-label={tr('gitSidebar.aria.openChanges')}>
{statusFiles.map((file) => (
<button
key={file.path}
@@ -524,36 +526,36 @@ export const GitSidebar: React.FC = () => {
</div>
<div className="git-sidebar-section git-sidebar-history">
<div className="sidebar-section-title">Version History ({historyEntries.length})</div>
<div className="git-sidebar-history-legend" aria-label="Commit status legend">
<div className="sidebar-section-title">{tr('gitSidebar.versionHistory', { count: historyEntries.length })}</div>
<div className="git-sidebar-history-legend" aria-label={tr('gitSidebar.aria.commitStatusLegend')}>
<span className="git-sidebar-history-legend-item">
<span
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--both"
data-testid="git-history-legend-both"
/>
Synced
{tr('gitSidebar.history.synced')}
</span>
<span className="git-sidebar-history-legend-item">
<span
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--local-only"
data-testid="git-history-legend-local-only"
/>
Local only
{tr('gitSidebar.history.localOnly')}
</span>
<span className="git-sidebar-history-legend-item">
<span
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--remote-only"
data-testid="git-history-legend-remote-only"
/>
Remote only
{tr('gitSidebar.history.remoteOnly')}
</span>
</div>
{historyLoading ? (
<div className="git-sidebar-empty-state">Loading history...</div>
<div className="git-sidebar-empty-state">{tr('gitSidebar.loadingHistory')}</div>
) : historyEntries.length === 0 ? (
<div className="git-sidebar-empty-state">No commits yet</div>
<div className="git-sidebar-empty-state">{tr('gitSidebar.noCommits')}</div>
) : (
<div className="git-sidebar-history-list" role="list" aria-label="Version History">
<div className="git-sidebar-history-list" role="list" aria-label={tr('gitSidebar.aria.versionHistory')}>
{historyEntries.map((entry) => (
<button
key={entry.hash}
@@ -576,12 +578,12 @@ export const GitSidebar: React.FC = () => {
))}
</div>
)}
{currentBranch && <div className="git-sidebar-empty-state">Branch: {currentBranch}</div>}
{currentBranch && <div className="git-sidebar-empty-state">{tr('gitSidebar.branch', { branch: currentBranch })}</div>}
{remoteState?.hasUpstream && remoteState.localBranch && remoteState.upstreamBranch && (
<div className="git-sidebar-empty-state">{remoteState.localBranch} {remoteState.upstreamBranch}</div>
)}
{remoteState?.hasUpstream && (
<div className="git-sidebar-empty-state">ahead {remoteState.ahead} / behind {remoteState.behind}</div>
<div className="git-sidebar-empty-state">{tr('gitSidebar.aheadBehind', { ahead: remoteState.ahead, behind: remoteState.behind })}</div>
)}
{remoteStateError && <div className="git-sidebar-empty-state git-sidebar-error">{remoteStateError}</div>}
</div>
@@ -605,20 +607,20 @@ export const GitSidebar: React.FC = () => {
return (
<div className="git-sidebar">
<div className="git-sidebar-header">SOURCE CONTROL</div>
<div className="git-sidebar-header">{tr('gitSidebar.header')}</div>
<div className="git-sidebar-empty">
<div className="git-sidebar-main">
<p>This project is not a git repository.</p>
<p>{tr('gitSidebar.notRepo')}</p>
<input
ref={remoteUrlInputRef}
className="git-sidebar-input"
type="text"
placeholder="Optional remote repository URL"
placeholder={tr('gitSidebar.placeholder.remoteUrl')}
disabled={initializing}
/>
{initializing && (
<p className="git-sidebar-progress">
{initProgress?.message || 'Initializing repository...'}
{initProgress?.message || tr('gitSidebar.progress.initializingRepo')}
{typeof initProgress?.progress === 'number' ? ` (${initProgress.progress}%)` : ''}
{initProgress?.detail ? `${initProgress.detail}` : ''}
</p>
@@ -629,7 +631,7 @@ export const GitSidebar: React.FC = () => {
onClick={handleInitialize}
disabled={initializing || !projectPath}
>
{initializing ? 'Initializing...' : 'Initialize Git'}
{initializing ? tr('gitSidebar.action.initializing') : tr('gitSidebar.action.initializeGit')}
</button>
</div>
{transcriptSection}

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../i18n';
import './InsertModal.css';
interface PostSearchResult {
@@ -54,6 +55,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
onClose,
initialText = '',
}) => {
const { t: tr } = useI18n();
const [activeTab, setActiveTab] = useState<Tab>('internal');
const [query, setQuery] = useState('');
const [externalUrl, setExternalUrl] = useState('');
@@ -164,10 +166,10 @@ export const InsertModal: React.FC<InsertModalProps> = ({
onInsertLink(externalUrl, externalText || undefined);
} else {
// External images don't have a mediaId
onInsertImage(externalUrl, externalAlt || 'Image', undefined);
onInsertImage(externalUrl, externalAlt || tr('insert.title.image'), undefined);
}
onClose();
}, [mode, externalUrl, externalText, externalAlt, onInsertLink, onInsertImage, onClose]);
}, [mode, externalUrl, externalText, externalAlt, onInsertLink, onInsertImage, onClose, tr]);
// Backdrop click handler
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
@@ -184,12 +186,12 @@ export const InsertModal: React.FC<InsertModalProps> = ({
}
}, [selectedIndex]);
const title = mode === 'link' ? 'Insert Link' : 'Insert Image';
const internalLabel = mode === 'link' ? 'Link to Post' : 'Media Library';
const externalLabel = mode === 'link' ? 'External URL' : 'External Image';
const title = mode === 'link' ? tr('insert.title.link') : tr('insert.title.image');
const internalLabel = mode === 'link' ? tr('insert.tab.linkInternal') : tr('insert.tab.imageInternal');
const externalLabel = mode === 'link' ? tr('insert.tab.linkExternal') : tr('insert.tab.imageExternal');
const searchPlaceholder = mode === 'link'
? 'Search posts by title or content...'
: 'Search media by name, title, or alt text...';
? tr('insert.searchPlaceholder.link')
: tr('insert.searchPlaceholder.image');
return (
<div className="insert-modal-backdrop" onClick={handleBackdropClick}>
@@ -228,18 +230,18 @@ export const InsertModal: React.FC<InsertModalProps> = ({
<div className="insert-modal-results">
{isSearching && (
<div className="insert-modal-status">Searching...</div>
<div className="insert-modal-status">{tr('insert.status.searching')}</div>
)}
{!isSearching && query.length < 2 && (
<div className="insert-modal-status">
Type at least 2 characters to search
{tr('insert.status.typeMore')}
</div>
)}
{!isSearching && query.length >= 2 && results.length === 0 && (
<div className="insert-modal-status">
No {mode === 'link' ? 'posts' : 'media'} found for "{query}"
{tr('insert.status.noResults', { kind: mode === 'link' ? tr('activity.posts').toLowerCase() : tr('activity.media').toLowerCase(), query })}
</div>
)}
@@ -277,12 +279,12 @@ export const InsertModal: React.FC<InsertModalProps> = ({
) : (
<div className="insert-modal-external">
<div className="insert-modal-field">
<label className="insert-modal-label">URL</label>
<label className="insert-modal-label">{tr('insert.label.url')}</label>
<input
ref={externalUrlRef}
type="text"
className="insert-modal-input"
placeholder={mode === 'link' ? 'https://example.com' : 'https://example.com/image.jpg'}
placeholder={mode === 'link' ? tr('insert.placeholder.linkUrl') : tr('insert.placeholder.imageUrl')}
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
autoComplete="off"
@@ -291,22 +293,22 @@ export const InsertModal: React.FC<InsertModalProps> = ({
{mode === 'link' ? (
<div className="insert-modal-field">
<label className="insert-modal-label">Link Text (optional)</label>
<label className="insert-modal-label">{tr('insert.label.linkTextOptional')}</label>
<input
type="text"
className="insert-modal-input"
placeholder="Click here"
placeholder={tr('insert.placeholder.linkText')}
value={externalText}
onChange={(e) => setExternalText(e.target.value)}
/>
</div>
) : (
<div className="insert-modal-field">
<label className="insert-modal-label">Alt Text</label>
<label className="insert-modal-label">{tr('insert.label.altText')}</label>
<input
type="text"
className="insert-modal-input"
placeholder="Description of the image"
placeholder={tr('insert.placeholder.imageAlt')}
value={externalAlt}
onChange={(e) => setExternalAlt(e.target.value)}
/>
@@ -318,7 +320,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
onClick={handleExternalSubmit}
disabled={!externalUrl}
>
Insert {mode === 'link' ? 'Link' : 'Image'}
{mode === 'link' ? tr('insert.submit.link') : tr('insert.submit.image')}
</button>
</div>
)}
@@ -327,14 +329,14 @@ export const InsertModal: React.FC<InsertModalProps> = ({
<div className="insert-modal-footer-content">
<span className="insert-modal-hint">
{activeTab === 'internal'
? 'Use ↑↓ to navigate, Enter to select, Esc to close'
: 'Enter URL and press Enter or click button, Esc to close'}
? tr('insert.hint.internal')
: tr('insert.hint.external')}
</span>
{activeTab === 'internal' && (
<span className="insert-modal-format-hint">
{mode === 'link'
? 'Canonical: /YYYY/MM/DD/slug'
: 'Canonical: /media/YYYY/MM/file.ext'}
? tr('insert.hint.canonicalPost')
: tr('insert.hint.canonicalMedia')}
</span>
)}
</div>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useI18n } from '../../i18n';
import './Lightbox.css';
interface LightboxImage {
@@ -20,6 +21,7 @@ export const Lightbox: React.FC<LightboxProps> = ({
isOpen,
onClose,
}) => {
const { t: tr } = useI18n();
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [isZoomed, setIsZoomed] = useState(false);
@@ -88,7 +90,7 @@ export const Lightbox: React.FC<LightboxProps> = ({
<div className="lightbox-overlay" onClick={handleBackdropClick}>
<div className="lightbox-container">
{/* Close button */}
<button className="lightbox-close" onClick={onClose} title="Close (Esc)">
<button className="lightbox-close" onClick={onClose} title={tr('lightbox.close')}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
@@ -97,12 +99,12 @@ export const Lightbox: React.FC<LightboxProps> = ({
{/* Navigation arrows */}
{hasMultiple && (
<>
<button className="lightbox-nav lightbox-prev" onClick={handlePrev} title="Previous (←)">
<button className="lightbox-nav lightbox-prev" onClick={handlePrev} title={tr('lightbox.previous')}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
<button className="lightbox-nav lightbox-next" onClick={handleNext} title="Next (→)">
<button className="lightbox-nav lightbox-next" onClick={handleNext} title={tr('lightbox.next')}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { showToast } from '../Toast';
import { useI18n } from '../../i18n';
import './MetadataDiffPanel.css';
interface TableStats {
@@ -40,6 +41,7 @@ interface ScanResult {
type ScanPhase = 'idle' | 'loading-stats' | 'scanning' | 'complete';
export const MetadataDiffPanel: React.FC = () => {
const { t: tr } = useI18n();
const [stats, setStats] = useState<TableStats | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [scanPhase, setScanPhase] = useState<ScanPhase>('idle');
@@ -58,12 +60,12 @@ export const MetadataDiffPanel: React.FC = () => {
}
} catch (error) {
console.error('Failed to load stats:', error);
showToast.error('Failed to load database statistics');
showToast.error(tr('metadataDiff.error.loadStats'));
}
setScanPhase('idle');
};
loadStats();
}, []);
}, [tr]);
// Subscribe to task progress
useEffect(() => {
@@ -85,7 +87,7 @@ export const MetadataDiffPanel: React.FC = () => {
const handleScan = useCallback(async () => {
setScanPhase('scanning');
setProgress({ current: 0, total: 100, message: 'Starting scan...' });
setProgress({ current: 0, total: 100, message: tr('metadataDiff.progress.starting') });
setScanResult(null);
try {
@@ -99,10 +101,10 @@ export const MetadataDiffPanel: React.FC = () => {
setScanPhase('complete');
} catch (error) {
console.error('Scan failed:', error);
showToast.error('Failed to scan for differences');
showToast.error(tr('metadataDiff.error.scan'));
setScanPhase('idle');
}
}, []);
}, [tr]);
const toggleGroup = (field: string) => {
setExpandedGroups(prev => {
@@ -123,13 +125,13 @@ export const MetadataDiffPanel: React.FC = () => {
try {
const result = await window.electronAPI?.metadataDiff.syncDbToFile(postIds, group.label);
if (result) {
showToast.success(`Synced ${result.success} posts to files${result.failed > 0 ? `, ${result.failed} failed` : ''}`);
showToast.success(tr('metadataDiff.sync.dbToFile.success', { success: result.success, failed: result.failed > 0 ? `, ${result.failed} ${tr('metadataDiff.sync.failed')}` : '' }));
// Re-scan to update the view
handleScan();
}
} catch (error) {
console.error('Sync failed:', error);
showToast.error('Failed to sync to files');
showToast.error(tr('metadataDiff.sync.dbToFile.error'));
} finally {
setSyncingGroups(prev => {
const next = new Set(prev);
@@ -137,7 +139,7 @@ export const MetadataDiffPanel: React.FC = () => {
return next;
});
}
}, [handleScan]);
}, [handleScan, tr]);
const handleSyncFileToDb = useCallback(async (group: DiffGroup) => {
const postIds = group.posts.map(p => p.postId);
@@ -146,13 +148,13 @@ export const MetadataDiffPanel: React.FC = () => {
try {
const result = await window.electronAPI?.metadataDiff.syncFileToDb(postIds, group.field, group.label);
if (result) {
showToast.success(`Synced ${result.success} files to database${result.failed > 0 ? `, ${result.failed} failed` : ''}`);
showToast.success(tr('metadataDiff.sync.fileToDb.success', { success: result.success, failed: result.failed > 0 ? `, ${result.failed} ${tr('metadataDiff.sync.failed')}` : '' }));
// Re-scan to update the view
handleScan();
}
} catch (error) {
console.error('Sync failed:', error);
showToast.error('Failed to sync to database');
showToast.error(tr('metadataDiff.sync.fileToDb.error'));
} finally {
setSyncingGroups(prev => {
const next = new Set(prev);
@@ -160,7 +162,7 @@ export const MetadataDiffPanel: React.FC = () => {
return next;
});
}
}, [handleScan]);
}, [handleScan, tr]);
const formatValue = (value: unknown): string => {
if (Array.isArray(value)) {
@@ -174,28 +176,28 @@ export const MetadataDiffPanel: React.FC = () => {
return (
<div className="metadata-diff-panel">
<h2>Metadata Diff Tool</h2>
<h2>{tr('metadataDiff.title')}</h2>
<p style={{ marginBottom: 16, color: 'var(--descriptionForeground)', fontSize: 13 }}>
Compare post metadata between database and markdown files. Fix inconsistencies caused by bugs or manual edits.
{tr('metadataDiff.description')}
</p>
{/* Stats Section */}
{stats && (
<div className="diff-stats">
<div className="stat-item">
<span className="stat-label">Total Posts</span>
<span className="stat-label">{tr('metadataDiff.stats.totalPosts')}</span>
<span className="stat-value">{stats.totalPosts}</span>
</div>
<div className="stat-item">
<span className="stat-label">Published</span>
<span className="stat-label">{tr('metadataDiff.stats.published')}</span>
<span className="stat-value">{stats.publishedPosts}</span>
</div>
<div className="stat-item">
<span className="stat-label">Drafts</span>
<span className="stat-label">{tr('metadataDiff.stats.drafts')}</span>
<span className="stat-value">{stats.draftPosts}</span>
</div>
<div className="stat-item">
<span className="stat-label">Media Files</span>
<span className="stat-label">{tr('metadataDiff.stats.mediaFiles')}</span>
<span className="stat-value">{stats.totalMedia}</span>
</div>
</div>
@@ -204,7 +206,7 @@ export const MetadataDiffPanel: React.FC = () => {
{/* Progress Section */}
{scanPhase === 'scanning' && (
<div className="diff-progress">
<h3>Scanning published posts...</h3>
<h3>{tr('metadataDiff.progress.scanningPublished')}</h3>
<div className="progress-bar-container">
<div
className="progress-bar"
@@ -225,12 +227,12 @@ export const MetadataDiffPanel: React.FC = () => {
{scanPhase === 'scanning' ? (
<>
<span className="spinner" style={{ width: 14, height: 14 }} />
Scanning...
{tr('metadataDiff.progress.scanning')}
</>
) : scanResult ? (
'🔄 Re-scan'
`🔄 ${tr('metadataDiff.action.rescan')}`
) : (
'🔍 Scan for Differences'
`🔍 ${tr('metadataDiff.action.scan')}`
)}
</button>
</div>
@@ -240,11 +242,10 @@ export const MetadataDiffPanel: React.FC = () => {
<div className="diff-results">
<div className={`diff-summary ${scanResult.postsWithDifferences > 0 ? 'has-differences' : 'no-differences'}`}>
{scanResult.postsWithDifferences === 0 ? (
<> No differences found! All {scanResult.totalScanned} published posts are in sync.</>
<>{tr('metadataDiff.summary.noDiffs', { total: scanResult.totalScanned })}</>
) : (
<>
Found <strong>{scanResult.postsWithDifferences}</strong> posts with differences
out of {scanResult.totalScanned} published posts.
{tr('metadataDiff.summary.withDiffs', { count: scanResult.postsWithDifferences, total: scanResult.totalScanned })}
</>
)}
</div>
@@ -260,16 +261,16 @@ export const MetadataDiffPanel: React.FC = () => {
<span className={`chevron ${expandedGroups.has(group.field) ? 'expanded' : ''}`}>
</span>
{group.label} Differences
{tr('metadataDiff.group.differences', { label: group.label })}
</div>
<div className="diff-group-count">
<span className="badge">{group.posts.length} posts</span>
<span className="badge">{tr('metadataDiff.group.postsCount', { count: group.posts.length })}</span>
<div className="diff-group-actions" onClick={e => e.stopPropagation()}>
<button
className="db-to-file"
onClick={() => handleSyncDbToFile(group)}
disabled={syncingGroups.has(group.field)}
title="Update files with database values"
title={tr('metadataDiff.sync.dbToFile.title')}
>
DB File
</button>
@@ -277,7 +278,7 @@ export const MetadataDiffPanel: React.FC = () => {
className="file-to-db"
onClick={() => handleSyncFileToDb(group)}
disabled={syncingGroups.has(group.field)}
title="Update database with file values"
title={tr('metadataDiff.sync.fileToDb.title')}
>
File DB
</button>
@@ -291,13 +292,13 @@ export const MetadataDiffPanel: React.FC = () => {
{post.title || post.slug}
</div>
<div>
<div className="diff-value-label">Database</div>
<div className="diff-value-label">{tr('metadataDiff.value.database')}</div>
<div className="diff-value db-value" title={formatValue(post.dbValue)}>
{formatValue(post.dbValue)}
</div>
</div>
<div>
<div className="diff-value-label">File</div>
<div className="diff-value-label">{tr('metadataDiff.value.file')}</div>
<div className="diff-value file-value" title={formatValue(post.fileValue)}>
{formatValue(post.fileValue)}
</div>
@@ -314,7 +315,7 @@ export const MetadataDiffPanel: React.FC = () => {
{scanPhase === 'idle' && !scanResult && (
<div className="diff-empty">
<div className="icon">📊</div>
<div>Click "Scan for Differences" to compare database metadata with file metadata.</div>
<div>{tr('metadataDiff.empty')}</div>
</div>
)}
</div>

View File

@@ -23,6 +23,7 @@ import '../../macros';
import './MilkdownEditor.css';
import { InsertModal } from '../InsertModal';
import { normalizeMilkdownMarkdown } from '../../utils/markdownEscape';
import { useI18n } from '../../i18n';
// Remark plugin to force tight lists (no blank lines between list items)
const remarkTightListsPlugin: Plugin<[Record<string, unknown>], Root> = () => {
@@ -89,6 +90,7 @@ interface EditorToolbarProps {
// Toolbar component that uses the editor instance
const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
const { t: tr } = useI18n();
const [loading, getEditor] = useInstance();
const [insertMode, setInsertMode] = useState<InsertModalMode>(null);
const [selectedText, setSelectedText] = useState('');
@@ -218,21 +220,21 @@ const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
<>
<div className="milkdown-toolbar">
<div className="toolbar-group">
<button onClick={() => insertHeading(1)} title="Heading 1">H1</button>
<button onClick={() => insertHeading(2)} title="Heading 2">H2</button>
<button onClick={() => insertHeading(3)} title="Heading 3">H3</button>
<button onClick={() => insertHeading(1)} title={tr('milkdown.heading1')}>H1</button>
<button onClick={() => insertHeading(2)} title={tr('milkdown.heading2')}>H2</button>
<button onClick={() => insertHeading(3)} title={tr('milkdown.heading3')}>H3</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => runCommand(toggleStrongCommand.key)} title="Bold (Ctrl+B)">
<button onClick={() => runCommand(toggleStrongCommand.key)} title={tr('milkdown.bold')}>
<strong>B</strong>
</button>
<button onClick={() => runCommand(toggleEmphasisCommand.key)} title="Italic (Ctrl+I)">
<button onClick={() => runCommand(toggleEmphasisCommand.key)} title={tr('milkdown.italic')}>
<em>I</em>
</button>
<button onClick={() => runCommand(toggleStrikethroughCommand.key)} title="Strikethrough">
<button onClick={() => runCommand(toggleStrikethroughCommand.key)} title={tr('milkdown.strikethrough')}>
<s>S</s>
</button>
</div>
@@ -240,25 +242,25 @@ const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => runCommand(wrapInBulletListCommand.key)} title="Bullet List"></button>
<button onClick={() => runCommand(wrapInOrderedListCommand.key)} title="Numbered List">1.</button>
<button onClick={() => runCommand(wrapInBlockquoteCommand.key)} title="Quote"></button>
<button onClick={() => runCommand(toggleInlineCodeCommand.key)} title="Code">{'{}'}</button>
<button onClick={() => runCommand(wrapInBulletListCommand.key)} title={tr('milkdown.bulletList')}></button>
<button onClick={() => runCommand(wrapInOrderedListCommand.key)} title={tr('milkdown.numberedList')}>1.</button>
<button onClick={() => runCommand(wrapInBlockquoteCommand.key)} title={tr('milkdown.quote')}></button>
<button onClick={() => runCommand(toggleInlineCodeCommand.key)} title={tr('milkdown.code')}>{'{}'}</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={openLinkModal} title="Insert Link (Ctrl+K)">🔗</button>
<button onClick={openImageModal} title="Insert Image">🖼</button>
<button onClick={() => runCommand(insertHrCommand.key)} title="Horizontal Rule"></button>
<button onClick={openLinkModal} title={tr('milkdown.insertLink')}>🔗</button>
<button onClick={openImageModal} title={tr('milkdown.insertImage')}>🖼</button>
<button onClick={() => runCommand(insertHrCommand.key)} title={tr('milkdown.horizontalRule')}></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => runCommand(undoCommand.key)} title="Undo (Ctrl+Z)"></button>
<button onClick={() => runCommand(redoCommand.key)} title="Redo (Ctrl+Y)"></button>
<button onClick={() => runCommand(undoCommand.key)} title={tr('milkdown.undo')}></button>
<button onClick={() => runCommand(redoCommand.key)} title={tr('milkdown.redo')}></button>
</div>
</div>
@@ -288,8 +290,10 @@ export const MilkdownEditor: React.FC<MilkdownEditorProps> = (props) => {
const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
content,
onChange,
placeholder = 'Start writing your content...',
placeholder,
}) => {
const { t: tr } = useI18n();
const resolvedPlaceholder = placeholder || tr('editor.placeholder');
const [loading, getEditor] = useInstance();
const lastExternalContent = useRef(content);
const isInternalChange = useRef(false);
@@ -375,7 +379,7 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
onInputCapture={markUserInteraction}
>
<EditorToolbar onUserInteraction={markUserInteraction} />
<div className="milkdown-content" data-placeholder={placeholder}>
<div className="milkdown-content" data-placeholder={resolvedPlaceholder}>
<Milkdown />
</div>
</div>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useI18n } from '../../i18n';
import './PostLinks.css';
interface PostLinkInfo {
@@ -14,6 +15,7 @@ interface PostLinksProps {
}
export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updatedAt }) => {
const { t: tr } = useI18n();
const [linksTo, setLinksTo] = useState<PostLinkInfo[]>([]);
const [linkedBy, setLinkedBy] = useState<PostLinkInfo[]>([]);
const [loading, setLoading] = useState(true);
@@ -44,7 +46,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
if (loading) {
return (
<div className="post-links">
<div className="post-links-loading">Loading links...</div>
<div className="post-links-loading">{tr('postLinks.loading')}</div>
</div>
);
}
@@ -60,7 +62,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
onClick={() => setExpanded(!expanded)}
>
<span className="post-links-icon">🔗</span>
<span className="post-links-count">{totalLinks} link{totalLinks !== 1 ? 's' : ''}</span>
<span className="post-links-count">{totalLinks} {totalLinks !== 1 ? tr('postLinks.links') : tr('postLinks.link')}</span>
<span className="post-links-chevron">{expanded ? '▼' : '▶'}</span>
</button>
@@ -70,7 +72,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
<div className="post-links-section">
<h4 className="post-links-heading">
<span className="post-links-arrow"></span>
Links to ({linksTo.length})
{tr('postLinks.linksTo', { count: linksTo.length })}
</h4>
<ul className="post-links-list">
{linksTo.map(link => (
@@ -78,7 +80,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
<button
className="post-link-item"
onClick={() => onPostClick?.(link.id)}
title={`Open: ${link.title}`}
title={tr('postLinks.openTitle', { title: link.title })}
>
{link.title}
</button>
@@ -92,7 +94,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
<div className="post-links-section">
<h4 className="post-links-heading">
<span className="post-links-arrow"></span>
Linked by ({linkedBy.length})
{tr('postLinks.linkedBy', { count: linkedBy.length })}
</h4>
<ul className="post-links-list">
{linkedBy.map(link => (
@@ -100,7 +102,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updat
<button
className="post-link-item"
onClick={() => onPostClick?.(link.id)}
title={`Open: ${link.title}`}
title={tr('postLinks.openTitle', { title: link.title })}
>
{link.title}
</button>

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import { useI18n } from '../../i18n';
import './SettingsView.css';
// Export category IDs for sidebar navigation
@@ -102,6 +103,7 @@ const SettingSection: React.FC<{
};
export const SettingsView: React.FC = () => {
const { t } = useI18n();
const {
preferredEditorMode,
setPreferredEditorMode,
@@ -255,10 +257,10 @@ export const SettingsView: React.FC = () => {
const handleSavePublishing = async () => {
try {
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
showToast.success('Publishing credentials saved');
showToast.success(t('settings.toast.publishingSaved'));
} catch (error) {
console.error('Failed to save publishing credentials:', error);
showToast.error('Failed to save credentials');
showToast.error(t('settings.toast.saveCredentialsFailed'));
}
};
@@ -278,7 +280,7 @@ export const SettingsView: React.FC = () => {
}
setCredentials(newCreds);
localStorage.setItem('bds-credentials', JSON.stringify(newCreds));
showToast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} credentials cleared`);
showToast.success(t('settings.toast.credentialsCleared', { type: type.toUpperCase() }));
};
// Save project settings
@@ -306,10 +308,10 @@ export const SettingsView: React.FC = () => {
categorySettings,
});
}
showToast.success('Project settings saved');
showToast.success(t('settings.toast.projectSaved'));
} catch (error) {
console.error('Failed to save project settings:', error);
showToast.error('Failed to save project settings');
showToast.error(t('settings.toast.projectSaveFailed'));
}
};
@@ -335,7 +337,7 @@ export const SettingsView: React.FC = () => {
const renderProjectSettings = () => (
<SettingSection
id="settings-section-project"
title="Project"
title={t('settings.project.title')}
description="General settings for the active blog project."
hidden={!sectionHasMatches(projectKeywords)}
>
@@ -380,12 +382,12 @@ export const SettingsView: React.FC = () => {
value={projectDataPath}
onChange={(e) => setProjectDataPath(e.target.value)}
/>
<button className="secondary" onClick={handleBrowseDataPath} title="Browse...">
Browse
<button className="secondary" onClick={handleBrowseDataPath} title={t('settings.project.browse')}>
{t('settings.project.browse')}
</button>
{projectDataPath && (
<button className="secondary" onClick={handleResetDataPath} title="Reset to default">
Reset
<button className="secondary" onClick={handleResetDataPath} title={t('settings.project.resetDefault')}>
{t('settings.project.reset')}
</button>
)}
</div>
@@ -415,26 +417,26 @@ export const SettingsView: React.FC = () => {
value={projectMainLanguage}
onChange={(e) => setProjectMainLanguage(e.target.value)}
>
<option value="en">English</option>
<option value="de">German (Deutsch)</option>
<option value="es">Spanish (Español)</option>
<option value="fr">French (Français)</option>
<option value="it">Italian (Italiano)</option>
<option value="pt">Portuguese (Português)</option>
<option value="nl">Dutch (Nederlands)</option>
<option value="pl">Polish (Polski)</option>
<option value="ru">Russian (Русский)</option>
<option value="ja">Japanese ()</option>
<option value="zh">Chinese ()</option>
<option value="ko">Korean ()</option>
<option value="ar">Arabic (العربية)</option>
<option value="hi">Hindi (ि)</option>
<option value="tr">Turkish (Türkçe)</option>
<option value="sv">Swedish (Svenska)</option>
<option value="da">Danish (Dansk)</option>
<option value="no">Norwegian (Norsk)</option>
<option value="fi">Finnish (Suomi)</option>
<option value="cs">Czech (Čeština)</option>
<option value="en">{t('settings.language.english')}</option>
<option value="de">{t('settings.language.german')}</option>
<option value="es">{t('settings.language.spanish')}</option>
<option value="fr">{t('settings.language.french')}</option>
<option value="it">{t('settings.language.italian')}</option>
<option value="pt">{t('settings.language.portuguese')}</option>
<option value="nl">{t('settings.language.dutch')}</option>
<option value="pl">{t('settings.language.polish')}</option>
<option value="ru">{t('settings.language.russian')}</option>
<option value="ja">{t('settings.language.japanese')}</option>
<option value="zh">{t('settings.language.chinese')}</option>
<option value="ko">{t('settings.language.korean')}</option>
<option value="ar">{t('settings.language.arabic')}</option>
<option value="hi">{t('settings.language.hindi')}</option>
<option value="tr">{t('settings.language.turkish')}</option>
<option value="sv">{t('settings.language.swedish')}</option>
<option value="da">{t('settings.language.danish')}</option>
<option value="no">{t('settings.language.norwegian')}</option>
<option value="fi">{t('settings.language.finnish')}</option>
<option value="cs">{t('settings.language.czech')}</option>
</select>
</SettingRow>
@@ -485,7 +487,7 @@ export const SettingsView: React.FC = () => {
const renderEditorSettings = () => (
<SettingSection
id="settings-section-editor"
title="Editor"
title={t('settings.editor.title')}
description="Configure the blog post editor behavior and appearance."
hidden={!sectionHasMatches(editorKeywords)}
>
@@ -499,9 +501,9 @@ export const SettingsView: React.FC = () => {
value={preferredEditorMode}
onChange={(e) => setPreferredEditorMode(e.target.value as 'wysiwyg' | 'markdown' | 'preview')}
>
<option value="wysiwyg">WYSIWYG (Visual Editor)</option>
<option value="markdown">Markdown (Source)</option>
<option value="preview">Preview (Read-only)</option>
<option value="wysiwyg">{t('settings.editor.mode.wysiwyg')}</option>
<option value="markdown">{t('settings.editor.mode.markdown')}</option>
<option value="preview">{t('settings.editor.mode.preview')}</option>
</select>
</SettingRow>
@@ -521,8 +523,8 @@ export const SettingsView: React.FC = () => {
})
}
>
<option value="inline">Inline</option>
<option value="side-by-side">Side by Side</option>
<option value="inline">{t('settings.editor.diff.inline')}</option>
<option value="side-by-side">{t('settings.editor.diff.sideBySide')}</option>
</select>
</SettingRow>
@@ -582,23 +584,23 @@ export const SettingsView: React.FC = () => {
setCategorySettings(nextSettings);
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
setNewCategoryInput('');
showToast.success(`Category "${trimmed}" added`);
showToast.success(t('settings.toast.categoryAdded', { category: trimmed }));
} catch (error) {
console.error('Failed to add category:', error);
showToast.error('Failed to add category');
showToast.error(t('settings.toast.categoryAddFailed'));
}
} else if (postCategories.includes(trimmed)) {
showToast.error('Category already exists');
showToast.error(t('settings.toast.categoryExists'));
}
};
const handleRemoveCategory = async (categoryToRemove: string) => {
if (PROTECTED_CATEGORIES.includes(categoryToRemove)) {
showToast.error(`Cannot delete standard category "${categoryToRemove}"`);
showToast.error(t('settings.toast.categoryProtected', { category: categoryToRemove }));
return;
}
if (postCategories.length <= 1) {
showToast.error('Must have at least one category');
showToast.error(t('settings.toast.categoryAtLeastOne'));
return;
}
try {
@@ -610,10 +612,10 @@ export const SettingsView: React.FC = () => {
delete nextSettings[categoryToRemove];
setCategorySettings(nextSettings);
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
showToast.success(`Category "${categoryToRemove}" removed`);
showToast.success(t('settings.toast.categoryRemoved', { category: categoryToRemove }));
} catch (error) {
console.error('Failed to remove category:', error);
showToast.error('Failed to remove category');
showToast.error(t('settings.toast.categoryRemoveFailed'));
}
};
@@ -636,10 +638,10 @@ export const SettingsView: React.FC = () => {
const defaults = { ...DEFAULT_CATEGORY_SETTINGS };
setCategorySettings(defaults);
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: defaults });
showToast.success('Categories reset to defaults');
showToast.success(t('settings.toast.categoriesReset'));
} catch (error) {
console.error('Failed to reset categories:', error);
showToast.error('Failed to reset categories');
showToast.error(t('settings.toast.categoriesResetFailed'));
}
};
@@ -662,14 +664,14 @@ export const SettingsView: React.FC = () => {
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
} catch (error) {
console.error('Failed to update category settings:', error);
showToast.error('Failed to update category settings');
showToast.error(t('settings.toast.categorySettingsUpdateFailed'));
}
};
const renderContentSettings = () => (
<SettingSection
id="settings-section-content"
title="Post Categories"
title={t('settings.content.title')}
description="Manage the available categories for blog posts. Each post can have one category that determines its display template."
hidden={!sectionHasMatches(contentKeywords)}
>
@@ -689,7 +691,7 @@ export const SettingsView: React.FC = () => {
checked={setting.renderInLists}
onChange={(event) => handleCategorySettingToggle(cat, 'renderInLists', event.target.checked)}
/>
<span>Render in lists</span>
<span>{t('settings.content.renderInLists')}</span>
</label>
<label className="category-setting-toggle" htmlFor={`category-${cat}-show-title`}>
<input
@@ -699,7 +701,7 @@ export const SettingsView: React.FC = () => {
checked={setting.showTitle}
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
/>
<span>Show titles</span>
<span>{t('settings.content.showTitles')}</span>
</label>
</div>
{!isProtected && (
@@ -748,13 +750,13 @@ export const SettingsView: React.FC = () => {
const result = await window.electronAPI?.chat.setSystemPrompt(aiSystemPrompt);
if (result?.success) {
setAiSystemPromptModified(false);
showToast.success('System prompt saved');
showToast.success(t('settings.toast.systemPromptSaved'));
} else {
showToast.error('Failed to save system prompt');
showToast.error(t('settings.toast.systemPromptSaveFailed'));
}
} catch (error) {
console.error('Failed to save system prompt:', error);
showToast.error('Failed to save system prompt');
showToast.error(t('settings.toast.systemPromptSaveFailed'));
}
};
@@ -766,11 +768,11 @@ export const SettingsView: React.FC = () => {
if (result?.success) {
setAiSystemPrompt(result.prompt || '');
setAiSystemPromptModified(false);
showToast.success('System prompt reset to default');
showToast.success(t('settings.toast.systemPromptReset'));
}
} catch (error) {
console.error('Failed to reset system prompt:', error);
showToast.error('Failed to reset system prompt');
showToast.error(t('settings.toast.systemPromptResetFailed'));
}
};
@@ -783,13 +785,13 @@ export const SettingsView: React.FC = () => {
setAiHasApiKey(true);
setAiApiKeyMasked('•'.repeat(Math.max(0, newApiKey.length - 4)) + newApiKey.slice(-4));
setNewApiKey('');
showToast.success('API key saved and validated');
showToast.success(t('settings.toast.apiKeySaved'));
} else {
showToast.error('Invalid API key');
showToast.error(t('settings.toast.apiKeyInvalid'));
}
} catch (error) {
console.error('Failed to save API key:', error);
showToast.error('Failed to save API key');
showToast.error(t('settings.toast.apiKeySaveFailed'));
}
};
@@ -798,18 +800,18 @@ export const SettingsView: React.FC = () => {
const result = await window.electronAPI?.chat.setDefaultModel(modelId);
if (result?.success) {
setSelectedModel(modelId);
showToast.success('Default model updated');
showToast.success(t('settings.toast.defaultModelUpdated'));
}
} catch (error) {
console.error('Failed to set model:', error);
showToast.error('Failed to set default model');
showToast.error(t('settings.toast.defaultModelUpdateFailed'));
}
};
const renderAISettings = () => (
<SettingSection
id="settings-section-ai"
title="AI Assistant"
title={t('settings.ai.title')}
description="Configure the AI chat assistant that helps you manage your blog content."
hidden={!sectionHasMatches(aiKeywords)}
>
@@ -865,7 +867,7 @@ export const SettingsView: React.FC = () => {
onChange={(e) => handleModelChange(e.target.value)}
disabled={!aiHasApiKey}
>
{availableModels.length === 0 && <option value="">No models available</option>}
{availableModels.length === 0 && <option value="">{t('settings.ai.noModels')}</option>}
{availableModels.map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
))}
@@ -908,7 +910,7 @@ export const SettingsView: React.FC = () => {
<>
<SettingSection
id="settings-section-publishing"
title="FTP Publishing"
title={t('settings.publishing.ftpTitle')}
description="Configure FTP credentials for publishing your blog to a web server."
hidden={!sectionHasMatches(publishingKeywords)}
>
@@ -964,13 +966,13 @@ export const SettingsView: React.FC = () => {
</SettingRow>
<div className="setting-actions">
<button className="primary" onClick={handleSavePublishing}>Save</button>
<button className="secondary danger" onClick={() => handleClearCredentials('ftp')}>Clear</button>
<button className="primary" onClick={handleSavePublishing}>{t('common.save')}</button>
<button className="secondary danger" onClick={() => handleClearCredentials('ftp')}>{t('common.clear')}</button>
</div>
</SettingSection>
<SettingSection
title="SSH Publishing"
title={t('settings.publishing.sshTitle')}
description="Configure SSH credentials for secure deployment to your server."
hidden={!sectionHasMatches(publishingKeywords)}
>
@@ -1017,8 +1019,8 @@ export const SettingsView: React.FC = () => {
</SettingRow>
<div className="setting-actions">
<button className="primary" onClick={handleSavePublishing}>Save</button>
<button className="secondary danger" onClick={() => handleClearCredentials('ssh')}>Clear</button>
<button className="primary" onClick={handleSavePublishing}>{t('common.save')}</button>
<button className="secondary danger" onClick={() => handleClearCredentials('ssh')}>{t('common.clear')}</button>
</div>
</SettingSection>
</>
@@ -1028,7 +1030,7 @@ export const SettingsView: React.FC = () => {
<>
<SettingSection
id="settings-section-data"
title="Database Maintenance"
title={t('settings.data.title')}
description="Rebuild the local database index from source files. Useful if post or media files were edited externally."
hidden={!sectionHasMatches(dataKeywords)}
>
@@ -1040,7 +1042,7 @@ export const SettingsView: React.FC = () => {
<button
className="secondary"
onClick={async () => {
showToast.loading('Rebuilding posts database...');
showToast.loading(t('settings.toast.rebuildPostsLoading'));
try {
await window.electronAPI?.posts.rebuildFromFiles();
const postsResult = await window.electronAPI?.posts.getAll({ limit: 500, offset: 0 });
@@ -1048,10 +1050,10 @@ export const SettingsView: React.FC = () => {
useAppStore.getState().setPosts(postsResult.items, postsResult.hasMore, postsResult.total);
}
showToast.dismiss();
showToast.success('Posts database rebuilt');
showToast.success(t('settings.toast.rebuildPostsSuccess'));
} catch {
showToast.dismiss();
showToast.error('Failed to rebuild posts database');
showToast.error(t('settings.toast.rebuildPostsFailed'));
}
}}
>
@@ -1067,7 +1069,7 @@ export const SettingsView: React.FC = () => {
<button
className="secondary"
onClick={async () => {
showToast.loading('Rebuilding media database...');
showToast.loading(t('settings.toast.rebuildMediaLoading'));
try {
await window.electronAPI?.media.rebuildFromFiles();
const media = await window.electronAPI?.media.getAll();
@@ -1075,10 +1077,10 @@ export const SettingsView: React.FC = () => {
useAppStore.getState().setMedia(media as any[]);
}
showToast.dismiss();
showToast.success('Media database rebuilt');
showToast.success(t('settings.toast.rebuildMediaSuccess'));
} catch {
showToast.dismiss();
showToast.error('Failed to rebuild media database');
showToast.error(t('settings.toast.rebuildMediaFailed'));
}
}}
>
@@ -1094,14 +1096,14 @@ export const SettingsView: React.FC = () => {
<button
className="secondary"
onClick={async () => {
showToast.loading('Rebuilding post links...');
showToast.loading(t('settings.toast.rebuildLinksLoading'));
try {
await window.electronAPI?.posts.rebuildLinks();
showToast.dismiss();
showToast.success('Post links rebuilt');
showToast.success(t('settings.toast.rebuildLinksSuccess'));
} catch {
showToast.dismiss();
showToast.error('Failed to rebuild post links');
showToast.error(t('settings.toast.rebuildLinksFailed'));
}
}}
>
@@ -1117,20 +1119,20 @@ export const SettingsView: React.FC = () => {
<button
className="secondary"
onClick={async () => {
showToast.loading('Generating thumbnails...');
showToast.loading(t('settings.toast.thumbnailsLoading'));
try {
const result = await window.electronAPI?.media.regenerateMissingThumbnails();
showToast.dismiss();
if (result && result.generated > 0) {
showToast.success(`Generated ${result.generated} thumbnails`);
showToast.success(t('settings.toast.thumbnailsGenerated', { count: result.generated }));
} else if (result && result.processed === 0) {
showToast.success('All thumbnails already exist');
showToast.success(t('settings.toast.thumbnailsAlreadyExist'));
} else {
showToast.success('Thumbnail generation complete');
showToast.success(t('settings.toast.thumbnailsComplete'));
}
} catch {
showToast.dismiss();
showToast.error('Failed to generate thumbnails');
showToast.error(t('settings.toast.thumbnailsFailed'));
}
}}
>
@@ -1140,7 +1142,7 @@ export const SettingsView: React.FC = () => {
</SettingSection>
<SettingSection
title="File System"
title={t('settings.data.fileSystemTitle')}
description="Access project data files and directories."
hidden={!sectionHasMatches(dataKeywords)}
>
@@ -1178,12 +1180,12 @@ export const SettingsView: React.FC = () => {
<div className="settings-view">
{/* Header with search */}
<div className="settings-header">
<h2>Settings</h2>
<h2>{t('common.settings')}</h2>
<div className="settings-search">
<span className="settings-search-icon"><SearchIcon /></span>
<input
type="text"
placeholder="Search settings..."
placeholder={t('settings.search.placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
@@ -1211,8 +1213,8 @@ export const SettingsView: React.FC = () => {
</>
) : (
<div className="settings-no-results">
<p>No settings found matching "{searchQuery}"</p>
<button onClick={() => setSearchQuery('')}>Clear search</button>
<p>{t('settings.search.noResults', { query: searchQuery })}</p>
<button onClick={() => setSearchQuery('')}>{t('settings.search.clear')}</button>
</div>
)}
</div>

View File

@@ -1,5 +1,6 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { useAppStore, Tab } from '../../store';
import { useI18n } from '../../i18n';
import './TabBar.css';
const MAX_CHAT_TITLE_LENGTH = 18;
@@ -22,7 +23,8 @@ const getTabTitle = (
media: { id: string; originalName: string }[],
chatTitles: Map<string, string>,
importDefTitles: Map<string, string>,
commitTitles: Map<string, string>
commitTitles: Map<string, string>,
tr: (key: string, vars?: Record<string, string | number>) => string,
): string => {
if (tab.type === 'git-diff') {
const filePath = getGitDiffResource(tab.id);
@@ -32,57 +34,57 @@ const getTabTitle = (
if (commitTitle) {
return commitTitle;
}
return `Commit ${commitHash.slice(0, 7)}`;
return tr('tabBar.commitTitle', { hash: commitHash.slice(0, 7) });
}
const filename = filePath.split('/').pop();
return filename || filePath;
}
if (tab.type === 'settings') {
return 'Settings';
return tr('common.settings');
}
if (tab.type === 'style') {
return 'Style';
return tr('tabBar.style');
}
if (tab.type === 'tags') {
return 'Tags';
return tr('activity.tags');
}
if (tab.type === 'post') {
return postTitles.get(tab.id) || 'Loading...';
return postTitles.get(tab.id) || tr('tabBar.loading');
}
if (tab.type === 'media') {
const mediaItem = media.find(m => m.id === tab.id);
return mediaItem?.originalName || 'Media';
return mediaItem?.originalName || tr('activity.media');
}
if (tab.type === 'chat') {
const title = chatTitles.get(tab.id);
if (title && title !== 'New Chat') {
if (title && title !== tr('chat.newChat')) {
// Truncate long titles for display
return title.length > MAX_CHAT_TITLE_LENGTH
? title.substring(0, MAX_CHAT_TITLE_LENGTH) + '…'
: title;
}
return 'New Chat';
return tr('chat.newChat');
}
if (tab.type === 'import') {
return importDefTitles.get(tab.id) || 'Import';
return importDefTitles.get(tab.id) || tr('activity.import');
}
if (tab.type === 'metadata-diff') {
return 'Metadata Diff';
return tr('app.metadataDiff');
}
if (tab.type === 'documentation') {
return 'Documentation';
return tr('docs.title');
}
return 'Unknown';
return tr('tabBar.unknown');
};
const getTabIcon = (tab: Tab): React.ReactNode => {
@@ -176,6 +178,7 @@ const ChevronRightIcon: React.FC = () => (
);
export const TabBar: React.FC = () => {
const { t: tr } = useI18n();
const {
tabs,
activeTabId,
@@ -218,7 +221,7 @@ export const TabBar: React.FC = () => {
continue;
}
const title = post.title || 'Untitled';
const title = post.title || tr('editor.untitled');
if (next.get(post.id) !== title) {
next.set(post.id, title);
changed = true;
@@ -241,11 +244,11 @@ export const TabBar: React.FC = () => {
try {
const post = await window.electronAPI?.posts.get(tab.id);
if (post) {
newTitles.set(tab.id, post.title || 'Untitled');
newTitles.set(tab.id, post.title || tr('editor.untitled'));
changed = true;
}
} catch (error) {
console.error('Failed to fetch post title:', error);
console.error(tr('tabBar.error.fetchPostTitle'), error);
}
}
}
@@ -256,7 +259,7 @@ export const TabBar: React.FC = () => {
};
fetchTitles();
}, [tabs, posts]); // Note: intentionally not including postTitles to avoid infinite loops
}, [tabs, posts, tr]); // Note: intentionally not including postTitles to avoid infinite loops
// Listen for post updates to refresh titles
useEffect(() => {
@@ -265,7 +268,7 @@ export const TabBar: React.FC = () => {
if (post) {
setPostTitles(prev => {
const newTitles = new Map(prev);
newTitles.set(post.id, post.title || 'Untitled');
newTitles.set(post.id, post.title || tr('editor.untitled'));
return newTitles;
});
}
@@ -274,7 +277,7 @@ export const TabBar: React.FC = () => {
return () => {
unsub?.();
};
}, []);
}, [tr]);
// Fetch chat titles for chat tabs
useEffect(() => {
@@ -293,7 +296,7 @@ export const TabBar: React.FC = () => {
newTitles.set(tab.id, conversation.title);
}
} catch (error) {
console.error('Failed to fetch chat title:', error);
console.error(tr('tabBar.error.fetchChatTitle'), error);
}
}
}
@@ -304,7 +307,7 @@ export const TabBar: React.FC = () => {
};
fetchTitles();
}, [tabs]); // Note: intentionally not including chatTitles to avoid infinite loops
}, [tabs, tr]); // Note: intentionally not including chatTitles to avoid infinite loops
// Listen for chat title updates
useEffect(() => {
@@ -336,7 +339,7 @@ export const TabBar: React.FC = () => {
newTitles.set(tab.id, def.name);
}
} catch (error) {
console.error('Failed to fetch import definition title:', error);
console.error(tr('tabBar.error.fetchImportTitle'), error);
}
}
}
@@ -346,7 +349,7 @@ export const TabBar: React.FC = () => {
};
fetchTitles();
}, [tabs]); // Note: intentionally not including importDefTitles to avoid infinite loops
}, [tabs, tr]); // Note: intentionally not including importDefTitles to avoid infinite loops
// Listen for import definition name updates
useEffect(() => {
@@ -411,7 +414,7 @@ export const TabBar: React.FC = () => {
return changed ? updated : previous;
});
} catch (error) {
console.error('Failed to fetch commit titles:', error);
console.error(tr('tabBar.error.fetchCommitTitle'), error);
}
};
@@ -420,7 +423,7 @@ export const TabBar: React.FC = () => {
return () => {
cancelled = true;
};
}, [tabs, activeProject]);
}, [tabs, activeProject, tr]);
// Check if arrows are needed based on scroll position
const updateArrowVisibility = useCallback(() => {
@@ -534,7 +537,7 @@ export const TabBar: React.FC = () => {
<button
className="tab-scroll-button tab-scroll-left"
onClick={scrollLeft}
title="Scroll tabs left"
title={tr('tabBar.scrollLeft')}
>
<ChevronLeftIcon />
</button>
@@ -544,7 +547,7 @@ export const TabBar: React.FC = () => {
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id);
const title = getTabTitle(tab, postTitles, media, chatTitles, importDefTitles, commitTitles);
const title = getTabTitle(tab, postTitles, media, chatTitles, importDefTitles, commitTitles, tr);
const icon = getTabIcon(tab);
return (
@@ -555,7 +558,7 @@ export const TabBar: React.FC = () => {
onClick={() => handleTabClick(tab.id)}
onDoubleClick={() => handleTabDoubleClick(tab)}
onMouseDown={(e) => handleMiddleClick(e, tab.id)}
title={`${title}${tab.isTransient ? ' (Preview)' : ''}${isDirty ? ' • Modified' : ''}`}
title={`${title}${tab.isTransient ? ` (${tr('tabBar.preview')})` : ''}${isDirty ? `${tr('tabBar.modified')}` : ''}`}
>
<span className="tab-icon">{icon}</span>
<span className={`tab-title ${tab.isTransient ? 'italic' : ''}`}>
@@ -566,7 +569,7 @@ export const TabBar: React.FC = () => {
<button
className="tab-close"
onClick={(e) => handleTabClose(e, tab.id)}
title="Close (Ctrl+W)"
title={tr('tabBar.closeHint')}
>
<CloseIcon />
</button>
@@ -580,7 +583,7 @@ export const TabBar: React.FC = () => {
<button
className="tab-scroll-button tab-scroll-right"
onClick={scrollRight}
title="Scroll tabs right"
title={tr('tabBar.scrollRight')}
>
<ChevronRightIcon />
</button>

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { useAppStore } from '../../store';
import type { TaskProgress } from '../../../main/shared/electronApi';
import { useI18n } from '../../i18n';
import './TaskPopup.css';
interface GroupedTaskEntry {
@@ -70,6 +71,7 @@ function buildTaskEntries(tasks: TaskProgress[]): TaskEntry[] {
}
export const TaskPopup: React.FC = () => {
const { t } = useI18n();
const { tasks } = useAppStore();
const [isOpen, setIsOpen] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
@@ -174,7 +176,7 @@ export const TaskPopup: React.FC = () => {
<button
className="task-cancel"
onClick={() => handleCancel(task.taskId)}
title="Cancel task"
title={t('tasks.cancelTask')}
>
</button>
@@ -216,57 +218,57 @@ export const TaskPopup: React.FC = () => {
<button
className={`task-popup-trigger ${hasActiveTasks ? 'active' : ''}`}
onClick={() => setIsOpen(!isOpen)}
title={`${runningTasks.length} running, ${pendingTasks.length} pending`}
title={t('tasks.triggerTitle', { running: runningTasks.length, pending: pendingTasks.length })}
>
{runningTasks.length > 0 ? (
<>
<span className="task-spinner" />
<span>{runningTasks.length} running</span>
<span>{`${runningTasks.length} ${t('common.running')}`}</span>
</>
) : pendingTasks.length > 0 ? (
<>
<span className="task-icon pending"></span>
<span>{pendingTasks.length} pending</span>
<span>{`${pendingTasks.length} ${t('common.pending')}`}</span>
</>
) : (
<span>Tasks</span>
<span>{t('common.tasks')}</span>
)}
</button>
{isOpen && (
<div className="task-popup">
<div className="task-popup-header">
<h4>Background Tasks</h4>
<h4>{t('tasks.backgroundTasks')}</h4>
{recentTasks.length > 0 && (
<button className="text-button" onClick={handleClearCompleted}>
Clear completed
{t('tasks.clearCompleted')}
</button>
)}
</div>
{runningTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Running</div>
<div className="task-section-title">{t('common.running')}</div>
{renderEntries(runningEntries)}
</div>
)}
{pendingTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Pending</div>
<div className="task-section-title">{t('common.pending')}</div>
{renderEntries(pendingEntries)}
</div>
)}
{recentTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Recent</div>
<div className="task-section-title">{t('tasks.recent')}</div>
{renderEntries(recentEntries)}
</div>
)}
{runningTasks.length === 0 && pendingTasks.length === 0 && recentTasks.length === 0 && (
<div className="task-empty">No active tasks</div>
<div className="task-empty">{t('tasks.noActive')}</div>
)}
</div>
)}

View File

@@ -1,6 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAppStore } from '../../store';
import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands';
import { resolveSupportedUiLanguage, translateMenu } from '../../../main/shared/i18n';
import { useI18n } from '../../i18n';
import './WindowTitleBar.css';
type WindowControlsOverlayLike = {
@@ -11,6 +13,7 @@ type WindowControlsOverlayLike = {
};
export const WindowTitleBar: React.FC = () => {
const { language } = useI18n();
const { sidebarVisible, panelVisible, toggleSidebar, togglePanel } = useAppStore();
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
@@ -33,6 +36,19 @@ export const WindowTitleBar: React.FC = () => {
};
});
const uiLanguage = resolveSupportedUiLanguage(language);
const getGroupDisplayLabel = (groupLabel: string): string => {
return translateMenu(uiLanguage, `menu.group.${groupLabel.toLowerCase()}`) || groupLabel;
};
const getItemDisplayLabel = (itemLabel: string): string => {
if (itemLabel.startsWith('menu.')) {
return translateMenu(uiLanguage, itemLabel) || itemLabel;
}
return itemLabel;
};
const mnemonicByKey = useMemo(() => {
return visibleMenuGroups.reduce<Record<string, string>>((acc, group) => {
const mnemonicKey = group.label.charAt(0).toLowerCase();
@@ -247,7 +263,7 @@ export const WindowTitleBar: React.FC = () => {
const typed = event.key.toLowerCase();
const matchingIndices = actionableItems
.map((item, index) => ({ item, index }))
.filter(entry => entry.item.label.toLowerCase().startsWith(typed))
.filter(entry => getItemDisplayLabel(entry.item.label).toLowerCase().startsWith(typed))
.map(entry => entry.index);
if (matchingIndices.length === 0) {
@@ -402,7 +418,7 @@ export const WindowTitleBar: React.FC = () => {
onMouseEnter={(event) => handleMenuButtonMouseEnter(event, group.label)}
aria-label={group.label}
>
{renderMenuLabel(group.label)}
{renderMenuLabel(getGroupDisplayLabel(group.label))}
</button>
))}
</div>
@@ -452,6 +468,7 @@ export const WindowTitleBar: React.FC = () => {
return <div key={item.action} className="window-titlebar-menu-separator" />;
}
const displayLabel = getItemDisplayLabel(item.label);
const acceleratorText = item.accelerator ? formatAccelerator(item.accelerator) : null;
const actionableItems = activeMenu.items.filter(menuItem => !menuItem.separator);
const currentActionableIndex = actionableItems.findIndex(menuItem => menuItem.action === item.action);
@@ -463,9 +480,9 @@ export const WindowTitleBar: React.FC = () => {
type="button"
className={`window-titlebar-menu-item${isKeyboardActive ? ' is-keyboard-active' : ''}`}
onClick={() => handleMenuItemClick(item.action)}
aria-label={acceleratorText ? `${item.label} ${acceleratorText}` : item.label}
aria-label={acceleratorText ? `${displayLabel} ${acceleratorText}` : displayLabel}
>
<span className="window-titlebar-menu-item-label">{item.label}</span>
<span className="window-titlebar-menu-item-label">{displayLabel}</span>
{acceleratorText && <span className="window-titlebar-menu-item-accelerator">{acceleratorText}</span>}
</button>
);

108
src/renderer/i18n/index.tsx Normal file
View 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);
}

View 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."
}

View 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."
}

View 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."
}

View 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 douvrir laperçu de larticle 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 darticles",
"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 darticles 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 lassistant 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 dimage IA",
"aiSuggestions.close": "Fermer",
"aiSuggestions.analyzing": "Analyse de limage...",
"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 lIA à appliquer. Les valeurs existantes sont conservées par défaut.",
"aiSuggestions.empty": "Aucune suggestion na é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 limage",
"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 lURL 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 larticle",
"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 dutilisateur",
"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 dactualiser létat de suivi distant.",
"gitSidebar.error.gitMissing": "Exécutable Git introuvable. Veuillez installer Git et redémarrer lapplication.",
"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 linitialisation 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 dinitialisation",
"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 lhistorique...",
"gitSidebar.noCommits": "Aucun commit pour le moment",
"gitSidebar.branch": "Branche : {branch}",
"gitSidebar.aheadBehind": "en avance {ahead} / en retard {behind}",
"gitSidebar.notRepo": "Ce projet nest 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 lanalyse...",
"metadataDiff.progress.scanningPublished": "Scanning published articles...",
"metadataDiff.progress.scanning": "Analyse en cours...",
"metadataDiff.action.scan": "Analyser les différences",
"metadataDiff.action.rescan": "Relancer lanalyse",
"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."
}

View 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 lanteprima 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 nellassistente 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 dellimmagine 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 dallIA 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 dellimmagine",
"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": "Leliminazione 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 lapp.",
"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."
}

View 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 tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import App from './App';
import { I18nProvider } from './i18n';
import './styles/global.css';
// Configure Monaco web workers
@@ -37,6 +38,8 @@ loader.config({ monaco });
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<I18nProvider>
<App />
</I18nProvider>
</React.StrictMode>
);

View File

@@ -578,10 +578,10 @@ describe('PreviewServer', () => {
expect(firstPageHtml).toContain('<h1 class="archive-heading">Meine Blog Beschreibung</h1>');
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 = [
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') }),
@@ -604,7 +604,7 @@ describe('PreviewServer', () => {
await server.start(0);
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 () => {

View 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);
}
});
});

View 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');
});
});

View 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');
});
});

View 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);
}
});
});

View File

@@ -40,12 +40,11 @@ describe('Help menu documentation entry', () => {
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 generateSiteItem = blogGroup?.items.find((item) => item.action === 'generateSitemap');
expect(generateSiteItem).toBeDefined();
expect(generateSiteItem?.label).toBe('Render Site');
expect(generateSiteItem?.accelerator).toBe('CmdOrCtrl+R');
});
});