Feature/ai post suggestions (#40)

* feat: first cut on ai suggestion system for title and summary

* feat: completion of titling/excerpt/slug-suggestion AI quick action

* feat: feeds use existing excerpts. also documentation.

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-07 09:54:13 +01:00
committed by GitHub
parent 72b21ddba7
commit 9871cb827f
30 changed files with 1270 additions and 245 deletions

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useI18n } from '../../i18n';
import './AISuggestionsModal.css';
// Keep legacy types for backward-compatible re-export
export interface AISuggestions {
title?: string;
alt?: string;
@@ -14,52 +15,55 @@ export interface CurrentValues {
caption: string;
}
type SuggestionFieldKey = 'title' | 'alt' | 'caption';
interface SuggestionFieldConfig {
key: SuggestionFieldKey;
/**
* Generic field definition for the AI suggestions modal.
* Each field represents one suggestion the user can accept or reject.
*/
export interface SuggestionField {
key: string;
label: string;
currentValue: string;
suggestedValue?: string;
disabled?: boolean;
warning?: string;
}
const SUGGESTION_FIELDS: SuggestionFieldConfig[] = [
{ key: 'title', label: 'aiSuggestions.titleField' },
{ key: 'alt', label: 'aiSuggestions.altField' },
{ key: 'caption', label: 'aiSuggestions.captionField' },
];
interface AISuggestionsModalProps {
isOpen: boolean;
isLoading: boolean;
suggestions: AISuggestions | null;
currentValues: CurrentValues;
fields: SuggestionField[];
error?: string;
onConfirm: (values: Partial<AISuggestions>) => void;
modalTitle: string;
loadingText: string;
emptyText: string;
onConfirm: (values: Record<string, string>) => void;
onClose: () => void;
}
export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
isOpen,
isLoading,
suggestions,
currentValues,
fields,
error,
modalTitle,
loadingText,
emptyText,
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);
const [useCaption, setUseCaption] = useState(false);
// Dynamic checkbox state keyed by field key
const [checked, setChecked] = useState<Record<string, boolean>>({});
// Update checkbox state when suggestions arrive, based on whether current fields are empty
// Auto-check fields when suggestions arrive:
// checked only when there IS a suggestion AND current value is empty
useEffect(() => {
if (suggestions) {
setUseTitle(suggestions.title ? !currentValues.title : false);
setUseAlt(suggestions.alt ? !currentValues.alt : false);
setUseCaption(suggestions.caption ? !currentValues.caption : false);
const initial: Record<string, boolean> = {};
for (const field of fields) {
initial[field.key] = !field.disabled && !!field.suggestedValue && !field.currentValue;
}
}, [suggestions, currentValues]);
setChecked(initial);
}, [fields]);
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isLoading) {
@@ -68,68 +72,30 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
}, [isLoading, onClose]);
const handleConfirm = useCallback(() => {
const valuesToApply: Partial<AISuggestions> = {};
if (useTitle && suggestions?.title) valuesToApply.title = suggestions.title;
if (useAlt && suggestions?.alt) valuesToApply.alt = suggestions.alt;
if (useCaption && suggestions?.caption) valuesToApply.caption = suggestions.caption;
const valuesToApply: Record<string, string> = {};
for (const field of fields) {
if (checked[field.key] && field.suggestedValue) {
valuesToApply[field.key] = field.suggestedValue;
}
}
onConfirm(valuesToApply);
}, [useTitle, useAlt, useCaption, suggestions, onConfirm]);
}, [checked, fields, onConfirm]);
const setFieldChecked = useCallback((key: string, value: boolean) => {
setChecked(prev => ({ ...prev, [key]: value }));
}, []);
if (!isOpen) return null;
const hasAnySuggestion = suggestions && (suggestions.title || suggestions.alt || suggestions.caption);
const hasAnySelected = useTitle || useAlt || useCaption;
const fieldSelection: Record<SuggestionFieldKey, [boolean, (checked: boolean) => void]> = {
title: [useTitle, setUseTitle],
alt: [useAlt, setUseAlt],
caption: [useCaption, setUseCaption],
};
const renderSuggestionField = (field: SuggestionFieldConfig) => {
if (!suggestions?.[field.key]) {
return null;
}
const [isChecked, setChecked] = fieldSelection[field.key];
const currentValue = currentValues[field.key];
const suggestedValue = suggestions[field.key];
return (
<div key={field.key} className="ai-suggestion-item">
<label className="ai-suggestion-checkbox">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => setChecked(e.target.checked)}
/>
<span className="checkmark"></span>
</label>
<div className="ai-suggestion-content">
<div className="ai-suggestion-label">
{field.label}
{currentValue && (
<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">
{tr('aiSuggestions.current')}: <em>{currentValue}</em>
</div>
)}
</div>
</div>
);
};
const fieldsWithSuggestions = fields.filter(f => !!f.suggestedValue);
const hasAnySuggestion = fieldsWithSuggestions.length > 0;
const hasAnySelected = Object.values(checked).some(v => v);
return (
<div className="ai-suggestions-modal-backdrop" onClick={handleBackdropClick}>
<div className="ai-suggestions-modal">
<div className="ai-suggestions-modal-header">
<h2>{tr('aiSuggestions.title')}</h2>
<h2>{modalTitle}</h2>
{!isLoading && (
<button className="ai-suggestions-modal-close" onClick={onClose} title={tr('aiSuggestions.close')}>
@@ -141,7 +107,7 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
{isLoading && (
<div className="ai-suggestions-loading">
<div className="ai-suggestions-spinner"></div>
<p>{tr('aiSuggestions.analyzing')}</p>
<p>{loadingText}</p>
</div>
)}
@@ -157,13 +123,44 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
<p className="ai-suggestions-intro">
{tr('aiSuggestions.intro')}
</p>
{SUGGESTION_FIELDS.map((field) => renderSuggestionField({ ...field, label: tr(field.label) }))}
{fieldsWithSuggestions.map((field) => (
<div key={field.key} className="ai-suggestion-item">
<label className="ai-suggestion-checkbox">
<input
type="checkbox"
checked={!!checked[field.key]}
disabled={field.disabled}
onChange={(e) => setFieldChecked(field.key, e.target.checked)}
/>
<span className="checkmark"></span>
</label>
<div className="ai-suggestion-content">
<div className="ai-suggestion-label">
{field.label}
{field.currentValue && (
<span className="ai-suggestion-has-value" title={tr('aiSuggestions.hasExisting')}>
{tr('aiSuggestions.hasExisting')}
</span>
)}
</div>
<div className="ai-suggestion-value">{field.suggestedValue}</div>
{field.warning && (
<div className="ai-suggestion-current">{field.warning}</div>
)}
{field.currentValue && (
<div className="ai-suggestion-current">
{tr('aiSuggestions.current')}: <em>{field.currentValue}</em>
</div>
)}
</div>
</div>
))}
</div>
)}
{!isLoading && !error && !hasAnySuggestion && suggestions && (
{!isLoading && !error && !hasAnySuggestion && fields.length > 0 && (
<div className="ai-suggestions-empty">
{tr('aiSuggestions.empty')}
{emptyText}
</div>
)}
</div>

View File

@@ -147,6 +147,12 @@
align-items: flex-start;
}
.editor-excerpt-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
.editor-meta {
display: flex;
flex-direction: column;

View File

@@ -24,7 +24,8 @@ import { TemplatesView } from '../TemplatesView/TemplatesView';
import { DuplicatesView } from '../DuplicatesView/DuplicatesView';
import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils';
import { InsertModal } from '../InsertModal';
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
import { AISuggestionsModal } from '../AISuggestionsModal/AISuggestionsModal';
import type { SuggestionField } from '../AISuggestionsModal/AISuggestionsModal';
import { openEntityTab } from '../../navigation/tabPolicy';
import { EditorRoute, resolveEditorRoute } from '../../navigation/editorRouting';
import { useEntityLoader, useSaveShortcut } from '../../navigation/useEntityEditor';
@@ -67,6 +68,7 @@ const autoSaveManager = new AutoSaveManager({
const update: Parameters<typeof window.electronAPI.posts.update>[1] = {};
if ('title' in changes) update.title = changes.title as string;
if ('content' in changes) update.content = changes.content as string;
if ('excerpt' in changes) update.excerpt = changes.excerpt as string;
if ('tags' in changes) {
const tagsStr = changes.tags as string;
update.tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
@@ -193,6 +195,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [excerpt, setExcerpt] = useState('');
const [author, setAuthor] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
@@ -209,11 +212,21 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const [showPostSearch, setShowPostSearch] = useState(false);
const [showMediaSearch, setShowMediaSearch] = useState(false);
const [metadataExpanded, setMetadataExpanded] = useState(true);
const [excerptExpanded, setExcerptExpanded] = useState(false);
const editorRef = useRef<unknown>(null);
// Token incremented to signal Monaco that it should re-read its defaultValue.
// This is used instead of controlled `value` to avoid cursor-reset races.
const [monacoResetToken, setMonacoResetToken] = useState(0);
// Quick actions state for AI post analysis
const [showPostQuickActions, setShowPostQuickActions] = useState(false);
const [projectLanguage, setProjectLanguage] = useState('en');
const postQuickActionsRef = useRef<HTMLDivElement>(null);
const [showPostAISuggestionsModal, setShowPostAISuggestionsModal] = useState(false);
const [isAnalyzingPost, setIsAnalyzingPost] = useState(false);
const [postAISuggestionFields, setPostAISuggestionFields] = useState<SuggestionField[]>([]);
const [postAIError, setPostAIError] = useState<string | undefined>(undefined);
const isDirty = checkIsDirty(postId);
// Listen for auto-save events to keep local post state in sync
@@ -325,6 +338,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
if (post && !isInitialized) {
setTitle(post.title);
setContent(post.content);
setExcerpt(post.excerpt || '');
setAuthor(post.author || '');
setTags(post.tags);
setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']);
@@ -349,10 +363,11 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
// Short-circuit: check cheap comparisons first (content changes on every keystroke)
const contentChanged = content !== post.content;
const titleChanged = title !== post.title;
const excerptChanged = excerpt !== (post.excerpt || '');
const authorChanged = author !== (post.author || '');
const templateSlugChanged = templateSlug !== ((post as PostData & { templateSlug?: string }).templateSlug || '');
const languageChanged = postLanguage !== (post.language || '');
const hasChanges = contentChanged || titleChanged || authorChanged || templateSlugChanged || languageChanged ||
const hasChanges = contentChanged || titleChanged || excerptChanged || authorChanged || templateSlugChanged || languageChanged ||
JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()) ||
JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort());
@@ -363,6 +378,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
autoSaveManager.notifyChange(postId, {
title,
content,
excerpt,
author,
tags: tags.join(', '),
categories: selectedCategories,
@@ -372,7 +388,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
} else {
markClean(postId);
}
}, [title, content, author, tags, selectedCategories, templateSlug, postLanguage, post, postId, isInitialized, isDirty, markDirty, markClean]);
}, [title, content, excerpt, author, tags, selectedCategories, templateSlug, postLanguage, post, postId, isInitialized, isDirty, markDirty, markClean]);
// Handle editor mode change and persist preference
const handleEditorModeChange = (mode: EditorMode) => {
@@ -391,6 +407,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const updated = await window.electronAPI?.posts.update(postId, {
title,
content,
excerpt: excerpt || undefined,
author: author || undefined,
language: postLanguage || undefined,
tags,
@@ -434,6 +451,100 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
setIsDetectingLanguage(false);
}
}, [title, content, isDetectingLanguage, tr]);
// Load project language for AI post analysis
useEffect(() => {
window.electronAPI?.meta?.getProjectMetadata?.()?.then(metadata => {
if (metadata?.mainLanguage) {
setProjectLanguage(metadata.mainLanguage);
}
});
}, []);
// Close quick actions menu when clicking outside
useEffect(() => {
if (!showPostQuickActions) return;
const handleClickOutside = (e: MouseEvent) => {
if (postQuickActionsRef.current && !postQuickActionsRef.current.contains(e.target as Node)) {
setShowPostQuickActions(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPostQuickActions]);
// Handle AI post analysis (title, excerpt, slug suggestions)
const handlePostAIAnalysis = useCallback(async () => {
if (!post || isAnalyzingPost) return;
setShowPostQuickActions(false);
setShowPostAISuggestionsModal(true);
setIsAnalyzingPost(true);
setPostAISuggestionFields([]);
setPostAIError(undefined);
try {
const result = await window.electronAPI?.chat.analyzePost(postId, projectLanguage);
if (result?.success) {
const slugLocked = !!post.publishedAt;
setPostAISuggestionFields([
{ key: 'title', label: tr('aiSuggestions.titleField'), currentValue: title, suggestedValue: result.title },
{ key: 'excerpt', label: tr('aiSuggestions.excerptField'), currentValue: excerpt, suggestedValue: result.excerpt },
{
key: 'slug',
label: tr('aiSuggestions.slugField'),
currentValue: post.slug,
suggestedValue: result.slug,
disabled: slugLocked,
warning: slugLocked ? tr('aiSuggestions.slugLockedWarning') : undefined,
},
]);
} else {
setPostAIError(result?.error || tr('editor.post.error.analyzePost'));
}
} catch (error) {
console.error('Failed to analyze post:', error);
setPostAIError((error as Error).message || tr('editor.post.error.analyzePost'));
} finally {
setIsAnalyzingPost(false);
}
}, [post, postId, projectLanguage, isAnalyzingPost, title, excerpt, tr]);
// Handle applying AI post suggestions
const handleApplyPostAISuggestions = useCallback(async (values: Record<string, string>) => {
setShowPostAISuggestionsModal(false);
if (Object.keys(values).length === 0) return;
try {
const updatePayload: Record<string, unknown> = {};
if (values.title) updatePayload.title = values.title;
if (values.excerpt) updatePayload.excerpt = values.excerpt;
if (values.slug && !post?.publishedAt) updatePayload.slug = values.slug;
const updated = await window.electronAPI?.posts.update(postId, updatePayload as Parameters<typeof window.electronAPI.posts.update>[1]);
if (updated) {
updatePost(postId, updated as Partial<PostData>);
setPost(prev => prev ? { ...prev, ...updated as Partial<PostData> } : prev);
// Update local state for fields that changed
if (values.title) setTitle(values.title);
if (values.excerpt) setExcerpt(values.excerpt);
markDirty(postId);
showToast.success(tr('editor.post.toast.aiApplied'));
}
} catch (error) {
console.error('Failed to apply AI suggestions:', error);
showToast.error(tr('editor.post.error.applyFailed'));
}
}, [post, postId, updatePost, markDirty, tr]);
// Close AI post suggestions modal
const handleClosePostAISuggestionsModal = useCallback(() => {
setShowPostAISuggestionsModal(false);
setPostAISuggestionFields([]);
setPostAIError(undefined);
}, []);
const handlePublish = async () => {
await handleSave();
try {
@@ -743,9 +854,34 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
{post.status}
</span>
{isSaving && <span className="auto-save-indicator">{tr('editor.saving')}</span>}
<div className="quick-actions-wrapper" ref={postQuickActionsRef}>
<button
className="secondary quick-actions-btn"
onClick={() => setShowPostQuickActions(!showPostQuickActions)}
disabled={isAnalyzingPost}
title={tr('editor.post.quickActions.title')}
>
{isAnalyzingPost ? tr('editor.post.quickActions.analyzing') : tr('editor.post.quickActions.button')}
</button>
{showPostQuickActions && (
<div className="quick-actions-menu">
<button
className="quick-action-item"
onClick={handlePostAIAnalysis}
disabled={isAnalyzingPost || !content}
>
<span className="quick-action-icon">🤖</span>
<span className="quick-action-text">
<strong>{tr('editor.post.quickActions.aiTitle')}</strong>
<small>{tr('editor.post.quickActions.aiDescription')}</small>
</span>
</button>
</div>
)}
</div>
{post.status === 'draft' && (
<button
onClick={handlePublish}
<button
onClick={handlePublish}
className="success"
title={tr('editor.publishTitle')}
>
@@ -880,6 +1016,27 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
</div>
</div>
)}
<button
className={`metadata-toggle ${excerptExpanded ? 'expanded' : ''}`}
onClick={() => setExcerptExpanded(v => !v)}
>
<span className="metadata-toggle-chevron">{excerptExpanded ? '▼' : '▶'}</span>
<span>{tr('editor.excerpt.toggle')}</span>
</button>
{excerptExpanded && (
<div className="editor-excerpt-panel">
<div className="editor-field">
<label>{tr('editor.field.excerpt')}</label>
<textarea
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
placeholder={tr('editor.placeholder.excerpt')}
rows={4}
/>
</div>
</div>
)}
<div className="editor-body">
<div className="editor-toolbar">
@@ -1038,6 +1195,19 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
onClose={() => setShowMediaSearch(false)}
/>
)}
{/* AI Post Suggestions Modal */}
<AISuggestionsModal
isOpen={showPostAISuggestionsModal}
isLoading={isAnalyzingPost}
fields={postAISuggestionFields}
modalTitle={tr('aiSuggestions.postTitle')}
loadingText={tr('aiSuggestions.analyzingPost')}
emptyText={tr('aiSuggestions.postEmpty')}
error={postAIError}
onConfirm={handleApplyPostAISuggestions}
onClose={handleClosePostAISuggestionsModal}
/>
</div>
);
};
@@ -1067,7 +1237,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
// AI suggestions modal state
const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [aiSuggestions, setAISuggestions] = useState<AISuggestions | null>(null);
const [aiSuggestionFields, setAISuggestionFields] = useState<Array<{ key: string; label: string; currentValue: string; suggestedValue?: string }>>([]);
const [aiError, setAIError] = useState<string | undefined>(undefined);
// Load project language setting
@@ -1096,22 +1266,22 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
// Handle AI image analysis for alt text and caption
const handleAIAnalysis = async () => {
if (!item || isAnalyzing) return;
setShowQuickActions(false);
setShowAISuggestionsModal(true);
setIsAnalyzing(true);
setAISuggestions(null);
setAISuggestionFields([]);
setAIError(undefined);
try {
const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage);
if (result?.success) {
setAISuggestions({
title: result.title,
alt: result.alt,
caption: result.caption,
});
setAISuggestionFields([
{ key: 'title', label: tr('aiSuggestions.titleField'), currentValue: title, suggestedValue: result.title },
{ key: 'alt', label: tr('aiSuggestions.altField'), currentValue: alt, suggestedValue: result.alt },
{ key: 'caption', label: tr('aiSuggestions.captionField'), currentValue: caption, suggestedValue: result.caption },
]);
} else {
setAIError(result?.error || tr('editor.media.error.analyzeImage'));
}
@@ -1124,7 +1294,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
};
// Handle applying AI suggestions
const handleApplyAISuggestions = (values: Partial<AISuggestions>) => {
const handleApplyAISuggestions = (values: Record<string, string>) => {
if (values.title) setTitle(values.title);
if (values.alt) setAlt(values.alt);
if (values.caption) setCaption(values.caption);
@@ -1551,8 +1721,10 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
<AISuggestionsModal
isOpen={showAISuggestionsModal}
isLoading={isAnalyzing}
suggestions={aiSuggestions}
currentValues={{ title, alt, caption }}
fields={aiSuggestionFields}
modalTitle={tr('aiSuggestions.title')}
loadingText={tr('aiSuggestions.analyzing')}
emptyText={tr('aiSuggestions.empty')}
error={aiError}
onConfirm={handleApplyAISuggestions}
onClose={handleCloseAISuggestionsModal}

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "Für dieses Bild wurden keine Vorschläge erstellt.",
"aiSuggestions.wait": "Bitte warten...",
"aiSuggestions.applySelected": "Ausgewählte übernehmen",
"aiSuggestions.postTitle": "KI-Beitragsanalyse",
"aiSuggestions.analyzingPost": "Beitrag wird analysiert…",
"aiSuggestions.excerptField": "Zusammenfassung / Auszug",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "Der Slug kann nur vor der ersten Veröffentlichung geändert werden.",
"aiSuggestions.postEmpty": "Für diesen Beitrag wurden keine Vorschläge generiert.",
"insert.title.link": "Link einfügen",
"insert.title.image": "Bild einfügen",
"insert.tab.linkInternal": "Mit Beitrag verlinken",
@@ -545,6 +551,7 @@
"editor.field.tags": "Schlagwörter",
"editor.field.author": "Autor",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Auszug",
"editor.field.categories": "Kategorien",
"editor.field.content": "Inhalt",
"editor.field.template": "Vorlage",
@@ -558,6 +565,7 @@
"language.es": "Spanisch",
"editor.placeholder.tags": "Tags hinzufügen...",
"editor.placeholder.author": "Autorenname",
"editor.placeholder.excerpt": "Optionale Zusammenfassung für Listen und Vorschauen",
"editor.placeholder.categories": "Kategorien hinzufügen...",
"editor.placeholder.startWriting": "Mit dem Schreiben beginnen...",
"editor.mode.visual": "Visuell",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Beitragsvorschau",
"editor.previewLoading": "Vorschau wird geladen...",
"editor.metadata.toggle": "Metadaten",
"editor.excerpt.toggle": "Auszug",
"editor.footer.created": "Erstellt",
"editor.footer.updated": "Aktualisiert",
"editor.footer.published": "Veröffentlicht",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "⚡ Schnellaktionen",
"editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen",
"editor.media.quickActions.aiDescription": "Analysiert das Bild und schlägt Metadaten vor",
"editor.post.quickActions.title": "Schnellaktionen",
"editor.post.quickActions.analyzing": "⏳ Wird analysiert…",
"editor.post.quickActions.button": "⚡ Schnellaktionen",
"editor.post.quickActions.aiTitle": "KI: Titel, Zusammenfassung & Slug vorschlagen",
"editor.post.quickActions.aiDescription": "Analysiert den Inhalt und schlägt Metadaten vor",
"editor.post.quickActions.detectLanguageDescription": "Sprache mit KI erkennen",
"editor.post.quickActions.detecting": "Erkennung…",
"editor.post.quickActions.languageDetected": "Sprache erkannt",
"editor.post.quickActions.detectLanguageFailed": "Spracherkennung fehlgeschlagen",
"editor.post.error.analyzePost": "Beitragsanalyse fehlgeschlagen",
"editor.post.error.applyFailed": "KI-Vorschläge konnten nicht angewendet werden",
"editor.post.toast.aiApplied": "KI-Vorschläge angewendet",
"editor.media.replaceFile": "Datei ersetzen",
"editor.media.field.fileName": "Dateiname",
"editor.media.field.type": "Typ",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "No suggestions were generated for this image.",
"aiSuggestions.wait": "Please wait...",
"aiSuggestions.applySelected": "Apply Selected",
"aiSuggestions.postTitle": "AI Post Analysis",
"aiSuggestions.analyzingPost": "Analyzing post…",
"aiSuggestions.excerptField": "Summary / Excerpt",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "Slug can only be changed before the first publish.",
"aiSuggestions.postEmpty": "No suggestions were generated for this post.",
"insert.title.link": "Insert Link",
"insert.title.image": "Insert Image",
"insert.tab.linkInternal": "Link to Post",
@@ -545,6 +551,7 @@
"editor.field.tags": "Tags",
"editor.field.author": "Author",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Excerpt",
"editor.field.categories": "Categories",
"editor.field.content": "Content",
"editor.field.template": "Template",
@@ -558,6 +565,7 @@
"language.es": "Spanish",
"editor.placeholder.tags": "Add tags...",
"editor.placeholder.author": "Author name",
"editor.placeholder.excerpt": "Optional summary for lists and previews",
"editor.placeholder.categories": "Add categories...",
"editor.placeholder.startWriting": "Start writing...",
"editor.mode.visual": "Visual",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Post preview",
"editor.previewLoading": "Loading preview...",
"editor.metadata.toggle": "Metadata",
"editor.excerpt.toggle": "Excerpt",
"editor.footer.created": "Created",
"editor.footer.updated": "Updated",
"editor.footer.published": "Published",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "⚡ Quick Actions",
"editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption",
"editor.media.quickActions.aiDescription": "Analyzes the image to suggest metadata",
"editor.post.quickActions.title": "Quick Actions",
"editor.post.quickActions.analyzing": "⏳ Analyzing…",
"editor.post.quickActions.button": "⚡ Quick Actions",
"editor.post.quickActions.aiTitle": "AI: Suggest Title, Summary & Slug",
"editor.post.quickActions.aiDescription": "Analyzes post content to suggest metadata",
"editor.post.quickActions.detectLanguageDescription": "Detect language using AI",
"editor.post.quickActions.detecting": "Detecting…",
"editor.post.quickActions.languageDetected": "Language detected",
"editor.post.quickActions.detectLanguageFailed": "Language detection failed",
"editor.post.error.analyzePost": "Failed to analyze post",
"editor.post.error.applyFailed": "Failed to apply AI suggestions",
"editor.post.toast.aiApplied": "AI suggestions applied",
"editor.media.replaceFile": "Replace File",
"editor.media.field.fileName": "File Name",
"editor.media.field.type": "Type",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "No se generaron sugerencias para esta imagen.",
"aiSuggestions.wait": "Por favor espera...",
"aiSuggestions.applySelected": "Aplicar seleccionados",
"aiSuggestions.postTitle": "Análisis IA del artículo",
"aiSuggestions.analyzingPost": "Analizando artículo…",
"aiSuggestions.excerptField": "Resumen / Extracto",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "El slug solo puede cambiarse antes de la primera publicación.",
"aiSuggestions.postEmpty": "No se generaron sugerencias para este artículo.",
"insert.title.link": "Insertar enlace",
"insert.title.image": "Insertar imagen",
"insert.tab.linkInternal": "Enlazar a entrada",
@@ -545,6 +551,7 @@
"editor.field.tags": "Etiquetas",
"editor.field.author": "Autor",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Extracto",
"editor.field.categories": "Categorías",
"editor.field.content": "Contenido",
"editor.field.template": "Plantilla",
@@ -558,6 +565,7 @@
"language.es": "Español",
"editor.placeholder.tags": "Agregar etiquetas...",
"editor.placeholder.author": "Nombre del autor",
"editor.placeholder.excerpt": "Resumen opcional para listas y vistas previas",
"editor.placeholder.categories": "Agregar categorías...",
"editor.placeholder.startWriting": "Empieza a escribir...",
"editor.mode.visual": "Visual",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Vista previa de la entrada",
"editor.previewLoading": "Cargando vista previa...",
"editor.metadata.toggle": "Metadatos",
"editor.excerpt.toggle": "Extracto",
"editor.footer.created": "Creado",
"editor.footer.updated": "Actualizado",
"editor.footer.published": "Publicado",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "✨ Analizar con IA",
"editor.media.quickActions.aiTitle": "Título sugerido por IA",
"editor.media.quickActions.aiDescription": "Genera automáticamente título, texto alternativo y pie de foto.",
"editor.post.quickActions.title": "Acciones rápidas",
"editor.post.quickActions.analyzing": "⏳ Analizando…",
"editor.post.quickActions.button": "⚡ Acciones rápidas",
"editor.post.quickActions.aiTitle": "IA: Sugerir título, resumen y slug",
"editor.post.quickActions.aiDescription": "Analiza el contenido para sugerir metadatos",
"editor.post.quickActions.detectLanguageDescription": "Detectar idioma con IA",
"editor.post.quickActions.detecting": "Detectando…",
"editor.post.quickActions.languageDetected": "Idioma detectado",
"editor.post.quickActions.detectLanguageFailed": "Error al detectar el idioma",
"editor.post.error.analyzePost": "Error al analizar el artículo",
"editor.post.error.applyFailed": "No se pudieron aplicar las sugerencias de IA",
"editor.post.toast.aiApplied": "Sugerencias de IA aplicadas",
"editor.media.replaceFile": "Reemplazar archivo",
"editor.media.field.fileName": "Nombre de archivo",
"editor.media.field.type": "Tipo",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "Aucune suggestion na été générée pour cette image.",
"aiSuggestions.wait": "Veuillez patienter...",
"aiSuggestions.applySelected": "Appliquer la sélection",
"aiSuggestions.postTitle": "Analyse IA de l'article",
"aiSuggestions.analyzingPost": "Analyse de l'article…",
"aiSuggestions.excerptField": "Résumé / Extrait",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "Le slug ne peut être modifié qu'avant la première publication.",
"aiSuggestions.postEmpty": "Aucune suggestion n'a été générée pour cet article.",
"insert.title.link": "Insérer un lien",
"insert.title.image": "Insérer une image",
"insert.tab.linkInternal": "Lier à un article",
@@ -545,6 +551,7 @@
"editor.field.tags": "Étiquettes",
"editor.field.author": "Auteur",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Extrait",
"editor.field.categories": "Catégories",
"editor.field.content": "Contenu",
"editor.field.template": "Modèle",
@@ -558,6 +565,7 @@
"language.es": "Espagnol",
"editor.placeholder.tags": "Ajouter des étiquettes...",
"editor.placeholder.author": "Nom de lauteur",
"editor.placeholder.excerpt": "Résumé facultatif pour les listes et aperçus",
"editor.placeholder.categories": "Ajouter des catégories...",
"editor.placeholder.startWriting": "Commencez à écrire...",
"editor.mode.visual": "Visuel",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Aperçu de larticle",
"editor.previewLoading": "Chargement de l'aperçu...",
"editor.metadata.toggle": "Métadonnées",
"editor.excerpt.toggle": "Extrait",
"editor.footer.created": "Créé",
"editor.footer.updated": "Mis à jour",
"editor.footer.published": "Publié",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "✨ Analyser avec lIA",
"editor.media.quickActions.aiTitle": "Titre suggéré par lIA",
"editor.media.quickActions.aiDescription": "Générez automatiquement un titre, un texte alternatif et une légende.",
"editor.post.quickActions.title": "Actions rapides",
"editor.post.quickActions.analyzing": "⏳ Analyse…",
"editor.post.quickActions.button": "⚡ Actions rapides",
"editor.post.quickActions.aiTitle": "IA : Suggérer titre, résumé et slug",
"editor.post.quickActions.aiDescription": "Analyse le contenu pour suggérer des métadonnées",
"editor.post.quickActions.detectLanguageDescription": "Détecter la langue avec l'IA",
"editor.post.quickActions.detecting": "Détection…",
"editor.post.quickActions.languageDetected": "Langue détectée",
"editor.post.quickActions.detectLanguageFailed": "Échec de la détection de la langue",
"editor.post.error.analyzePost": "Échec de l'analyse de l'article",
"editor.post.error.applyFailed": "Impossible d'appliquer les suggestions IA",
"editor.post.toast.aiApplied": "Suggestions IA appliquées",
"editor.media.replaceFile": "Remplacer le fichier",
"editor.media.field.fileName": "Nom du fichier",
"editor.media.field.type": "Type",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "Nessun suggerimento è stato generato per questa immagine.",
"aiSuggestions.wait": "Attendere...",
"aiSuggestions.applySelected": "Applica selezionati",
"aiSuggestions.postTitle": "Analisi IA dell'articolo",
"aiSuggestions.analyzingPost": "Analisi dell'articolo…",
"aiSuggestions.excerptField": "Riassunto / Estratto",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "Lo slug può essere modificato solo prima della prima pubblicazione.",
"aiSuggestions.postEmpty": "Nessun suggerimento generato per questo articolo.",
"insert.title.link": "Inserisci link",
"insert.title.image": "Inserisci immagine",
"insert.tab.linkInternal": "Collega al post",
@@ -545,6 +551,7 @@
"editor.field.tags": "Tag",
"editor.field.author": "Autore",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Estratto",
"editor.field.categories": "Categorie",
"editor.field.content": "Contenuto",
"editor.field.template": "Modello",
@@ -558,6 +565,7 @@
"language.es": "Spagnolo",
"editor.placeholder.tags": "Aggiungi tag...",
"editor.placeholder.author": "Nome autore",
"editor.placeholder.excerpt": "Riassunto facoltativo per elenchi e anteprime",
"editor.placeholder.categories": "Aggiungi categorie...",
"editor.placeholder.startWriting": "Inizia a scrivere...",
"editor.mode.visual": "Visuale",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Anteprima post",
"editor.previewLoading": "Caricamento anteprima...",
"editor.metadata.toggle": "Metadati",
"editor.excerpt.toggle": "Estratto",
"editor.footer.created": "Creato",
"editor.footer.updated": "Aggiornato",
"editor.footer.published": "Pubblicato",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "✨ Analizza con IA",
"editor.media.quickActions.aiTitle": "Titolo suggerito dallIA",
"editor.media.quickActions.aiDescription": "Genera automaticamente titolo, testo alternativo e didascalia.",
"editor.post.quickActions.title": "Azioni rapide",
"editor.post.quickActions.analyzing": "⏳ Analisi…",
"editor.post.quickActions.button": "⚡ Azioni rapide",
"editor.post.quickActions.aiTitle": "IA: Suggerisci titolo, riassunto e slug",
"editor.post.quickActions.aiDescription": "Analizza il contenuto per suggerire i metadati",
"editor.post.quickActions.detectLanguageDescription": "Rileva la lingua con l'IA",
"editor.post.quickActions.detecting": "Rilevamento…",
"editor.post.quickActions.languageDetected": "Lingua rilevata",
"editor.post.quickActions.detectLanguageFailed": "Rilevamento lingua non riuscito",
"editor.post.error.analyzePost": "Analisi dell'articolo fallita",
"editor.post.error.applyFailed": "Impossibile applicare i suggerimenti IA",
"editor.post.toast.aiApplied": "Suggerimenti IA applicati",
"editor.media.replaceFile": "Sostituisci file",
"editor.media.field.fileName": "Nome file",
"editor.media.field.type": "Tipo",