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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -229,6 +229,12 @@
|
||||
"aiSuggestions.empty": "Aucune suggestion n’a é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 l’auteur",
|
||||
"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 l’article",
|
||||
"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 l’IA",
|
||||
"editor.media.quickActions.aiTitle": "Titre suggéré par l’IA",
|
||||
"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",
|
||||
|
||||
@@ -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 dall’IA",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user