feat: ai suggestions with modal dialog

This commit is contained in:
2026-02-15 10:23:49 +01:00
parent d48ac150eb
commit 99e74e3b23
4 changed files with 544 additions and 13 deletions

View File

@@ -0,0 +1,275 @@
.ai-suggestions-modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.ai-suggestions-modal {
background: var(--color-bg-secondary, #1e1e1e);
border: 1px solid var(--color-border, #3c3c3c);
border-radius: 8px;
min-width: 500px;
max-width: 650px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.ai-suggestions-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border, #3c3c3c);
}
.ai-suggestions-modal-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text, #fff);
}
.ai-suggestions-modal-close {
background: none;
border: none;
color: var(--color-text-muted, #888);
cursor: pointer;
font-size: 18px;
padding: 4px 8px;
border-radius: 4px;
}
.ai-suggestions-modal-close:hover {
background: var(--color-bg-tertiary, #2a2a2a);
color: var(--color-text, #fff);
}
.ai-suggestions-modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
/* Loading state */
.ai-suggestions-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 16px;
}
.ai-suggestions-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border, #3c3c3c);
border-top-color: var(--color-accent, #0078d4);
border-radius: 50%;
animation: ai-spinner-spin 1s linear infinite;
}
@keyframes ai-spinner-spin {
to {
transform: rotate(360deg);
}
}
.ai-suggestions-loading p {
margin: 0;
color: var(--color-text-muted, #888);
font-size: 14px;
}
/* Error state */
.ai-suggestions-error {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: rgba(255, 100, 100, 0.1);
border: 1px solid rgba(255, 100, 100, 0.3);
border-radius: 6px;
color: var(--color-error, #f88);
}
.ai-suggestions-error .error-icon {
font-size: 20px;
}
/* Empty state */
.ai-suggestions-empty {
text-align: center;
padding: 40px 20px;
color: var(--color-text-muted, #888);
}
/* Suggestions intro */
.ai-suggestions-intro {
margin: 0 0 16px 0;
color: var(--color-text-muted, #888);
font-size: 13px;
}
/* Suggestion list */
.ai-suggestions-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-suggestion-item {
display: flex;
gap: 12px;
padding: 16px;
background: var(--color-bg-tertiary, #2a2a2a);
border-radius: 6px;
border: 1px solid var(--color-border, #3c3c3c);
}
/* Custom checkbox */
.ai-suggestion-checkbox {
position: relative;
display: flex;
align-items: flex-start;
padding-top: 2px;
cursor: pointer;
}
.ai-suggestion-checkbox input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.ai-suggestion-checkbox .checkmark {
width: 20px;
height: 20px;
background: var(--color-bg-secondary, #1e1e1e);
border: 2px solid var(--color-border, #4c4c4c);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.ai-suggestion-checkbox:hover .checkmark {
border-color: var(--color-accent, #0078d4);
}
.ai-suggestion-checkbox input:checked ~ .checkmark {
background: var(--color-accent, #0078d4);
border-color: var(--color-accent, #0078d4);
}
.ai-suggestion-checkbox input:checked ~ .checkmark::after {
content: '✓';
color: white;
font-size: 12px;
font-weight: bold;
}
/* Suggestion content */
.ai-suggestion-content {
flex: 1;
min-width: 0;
}
.ai-suggestion-label {
font-weight: 600;
color: var(--color-text, #fff);
font-size: 13px;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.ai-suggestion-has-value {
font-weight: 400;
font-size: 11px;
color: var(--color-text-muted, #888);
background: var(--color-bg-secondary, #1e1e1e);
padding: 2px 6px;
border-radius: 3px;
}
.ai-suggestion-value {
color: var(--color-text, #fff);
font-size: 14px;
line-height: 1.5;
word-break: break-word;
}
.ai-suggestion-current {
margin-top: 8px;
font-size: 12px;
color: var(--color-text-muted, #888);
padding-top: 8px;
border-top: 1px solid var(--color-border, #3c3c3c);
}
.ai-suggestion-current em {
font-style: normal;
color: var(--color-text-secondary, #ccc);
}
/* Footer */
.ai-suggestions-modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--color-border, #3c3c3c);
}
.ai-suggestions-modal-footer button {
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-suggestions-modal-footer .button-cancel {
background: transparent;
border: 1px solid var(--color-border, #4c4c4c);
color: var(--color-text, #fff);
}
.ai-suggestions-modal-footer .button-cancel:hover:not(:disabled) {
background: var(--color-bg-tertiary, #2a2a2a);
}
.ai-suggestions-modal-footer .button-cancel:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ai-suggestions-modal-footer .button-apply {
background: var(--color-accent, #0078d4);
border: 1px solid var(--color-accent, #0078d4);
color: white;
}
.ai-suggestions-modal-footer .button-apply:hover:not(:disabled) {
background: var(--color-accent-hover, #106ebe);
}
.ai-suggestions-modal-footer .button-apply:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,222 @@
import React, { useState, useEffect, useCallback } from 'react';
import './AISuggestionsModal.css';
export interface AISuggestions {
title?: string;
alt?: string;
caption?: string;
}
export interface CurrentValues {
title: string;
alt: string;
caption: string;
}
interface AISuggestionsModalProps {
isOpen: boolean;
isLoading: boolean;
suggestions: AISuggestions | null;
currentValues: CurrentValues;
error?: string;
onConfirm: (values: Partial<AISuggestions>) => void;
onClose: () => void;
}
export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
isOpen,
isLoading,
suggestions,
currentValues,
error,
onConfirm,
onClose,
}) => {
// 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);
// Update checkbox state when suggestions arrive, based on whether current fields are empty
useEffect(() => {
if (suggestions) {
setUseTitle(suggestions.title ? !currentValues.title : false);
setUseAlt(suggestions.alt ? !currentValues.alt : false);
setUseCaption(suggestions.caption ? !currentValues.caption : false);
}
}, [suggestions, currentValues]);
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isLoading) {
onClose();
}
}, [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;
onConfirm(valuesToApply);
}, [useTitle, useAlt, useCaption, suggestions, onConfirm]);
if (!isOpen) return null;
const hasAnySuggestion = suggestions && (suggestions.title || suggestions.alt || suggestions.caption);
const hasAnySelected = useTitle || useAlt || useCaption;
return (
<div className="ai-suggestions-modal-backdrop" onClick={handleBackdropClick}>
<div className="ai-suggestions-modal">
<div className="ai-suggestions-modal-header">
<h2>AI Image Analysis</h2>
{!isLoading && (
<button className="ai-suggestions-modal-close" onClick={onClose} title="Close">
</button>
)}
</div>
<div className="ai-suggestions-modal-body">
{isLoading && (
<div className="ai-suggestions-loading">
<div className="ai-suggestions-spinner"></div>
<p>Analyzing image...</p>
</div>
)}
{error && !isLoading && (
<div className="ai-suggestions-error">
<span className="error-icon"></span>
<span>{error}</span>
</div>
)}
{!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.
</p>
{suggestions?.title && (
<div className="ai-suggestion-item">
<label className="ai-suggestion-checkbox">
<input
type="checkbox"
checked={useTitle}
onChange={(e) => setUseTitle(e.target.checked)}
/>
<span className="checkmark"></span>
</label>
<div className="ai-suggestion-content">
<div className="ai-suggestion-label">
Title
{currentValues.title && (
<span className="ai-suggestion-has-value" title="This field already has a value">
(has existing value)
</span>
)}
</div>
<div className="ai-suggestion-value">{suggestions.title}</div>
{currentValues.title && (
<div className="ai-suggestion-current">
Current: <em>{currentValues.title}</em>
</div>
)}
</div>
</div>
)}
{suggestions?.alt && (
<div className="ai-suggestion-item">
<label className="ai-suggestion-checkbox">
<input
type="checkbox"
checked={useAlt}
onChange={(e) => setUseAlt(e.target.checked)}
/>
<span className="checkmark"></span>
</label>
<div className="ai-suggestion-content">
<div className="ai-suggestion-label">
Alt Text
{currentValues.alt && (
<span className="ai-suggestion-has-value" title="This field already has a value">
(has existing value)
</span>
)}
</div>
<div className="ai-suggestion-value">{suggestions.alt}</div>
{currentValues.alt && (
<div className="ai-suggestion-current">
Current: <em>{currentValues.alt}</em>
</div>
)}
</div>
</div>
)}
{suggestions?.caption && (
<div className="ai-suggestion-item">
<label className="ai-suggestion-checkbox">
<input
type="checkbox"
checked={useCaption}
onChange={(e) => setUseCaption(e.target.checked)}
/>
<span className="checkmark"></span>
</label>
<div className="ai-suggestion-content">
<div className="ai-suggestion-label">
Caption
{currentValues.caption && (
<span className="ai-suggestion-has-value" title="This field already has a value">
(has existing value)
</span>
)}
</div>
<div className="ai-suggestion-value">{suggestions.caption}</div>
{currentValues.caption && (
<div className="ai-suggestion-current">
Current: <em>{currentValues.caption}</em>
</div>
)}
</div>
</div>
)}
</div>
)}
{!isLoading && !error && !hasAnySuggestion && suggestions && (
<div className="ai-suggestions-empty">
No suggestions were generated for this image.
</div>
)}
</div>
<div className="ai-suggestions-modal-footer">
{isLoading ? (
<button className="button-cancel" disabled>
Please wait...
</button>
) : (
<>
<button className="button-cancel" onClick={onClose}>
Cancel
</button>
{hasAnySuggestion && (
<button
className="button-apply"
onClick={handleConfirm}
disabled={!hasAnySelected}
>
Apply Selected
</button>
)}
</>
)}
</div>
</div>
</div>
);
};

View File

@@ -16,6 +16,7 @@ import { ImportAnalysisView } from '../ImportAnalysisView';
import { AutoSaveManager } from '../../utils'; import { AutoSaveManager } from '../../utils';
import { parseMacros, getMacro } from '../../macros/registry'; import { parseMacros, getMacro } from '../../macros/registry';
import { InsertModal } from '../InsertModal'; import { InsertModal } from '../InsertModal';
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
import './Editor.css'; import './Editor.css';
/** Get display name for media: prefer title over originalName */ /** Get display name for media: prefer title over originalName */
@@ -1443,10 +1444,15 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
// Quick action menu state // Quick action menu state
const [showQuickActions, setShowQuickActions] = useState(false); const [showQuickActions, setShowQuickActions] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [projectLanguage, setProjectLanguage] = useState('en'); const [projectLanguage, setProjectLanguage] = useState('en');
const quickActionsRef = useRef<HTMLDivElement>(null); const quickActionsRef = useRef<HTMLDivElement>(null);
// AI suggestions modal state
const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [aiSuggestions, setAISuggestions] = useState<AISuggestions | null>(null);
const [aiError, setAIError] = useState<string | undefined>(undefined);
// Load project language setting // Load project language setting
useEffect(() => { useEffect(() => {
window.electronAPI?.meta.getProjectMetadata().then(metadata => { window.electronAPI?.meta.getProjectMetadata().then(metadata => {
@@ -1474,33 +1480,49 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
if (!item || isAnalyzing) return; if (!item || isAnalyzing) return;
setShowQuickActions(false); setShowQuickActions(false);
setShowAISuggestionsModal(true);
setIsAnalyzing(true); setIsAnalyzing(true);
setAISuggestions(null);
setAIError(undefined);
try { try {
const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage); const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage);
if (result?.success) { if (result?.success) {
if (result.title) setTitle(result.title); setAISuggestions({
if (result.alt) setAlt(result.alt); title: result.title,
if (result.caption) setCaption(result.caption); alt: result.alt,
showToast.success('AI analysis complete'); caption: result.caption,
} else {
showErrorModal({
title: 'AI Analysis Failed',
message: result?.error || 'Failed to analyze image',
}); });
} else {
setAIError(result?.error || 'Failed to analyze image');
} }
} catch (error) { } catch (error) {
console.error('Failed to analyze image:', error); console.error('Failed to analyze image:', error);
showErrorModal({ setAIError((error as Error).message || 'Failed to analyze image');
title: 'AI Analysis Error',
message: (error as Error).message || 'Failed to analyze image',
});
} finally { } finally {
setIsAnalyzing(false); setIsAnalyzing(false);
} }
}; };
// Handle applying AI suggestions
const handleApplyAISuggestions = (values: Partial<AISuggestions>) => {
if (values.title) setTitle(values.title);
if (values.alt) setAlt(values.alt);
if (values.caption) setCaption(values.caption);
setShowAISuggestionsModal(false);
if (Object.keys(values).length > 0) {
showToast.success('AI suggestions applied');
}
};
// Close AI suggestions modal
const handleCloseAISuggestionsModal = () => {
setShowAISuggestionsModal(false);
setAISuggestions(null);
setAIError(undefined);
};
// Load linked posts for this media and fetch their titles // Load linked posts for this media and fetch their titles
useEffect(() => { useEffect(() => {
const loadLinkedPosts = async () => { const loadLinkedPosts = async () => {
@@ -1875,6 +1897,17 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
</div> </div>
</div> </div>
</div> </div>
{/* AI Suggestions Modal */}
<AISuggestionsModal
isOpen={showAISuggestionsModal}
isLoading={isAnalyzing}
suggestions={aiSuggestions}
currentValues={{ title, alt, caption }}
error={aiError}
onConfirm={handleApplyAISuggestions}
onClose={handleCloseAISuggestionsModal}
/>
</div> </div>
); );
}; };

View File

@@ -18,6 +18,7 @@ export { PostLinks } from './PostLinks';
export { LinkedMediaPanel } from './LinkedMediaPanel'; export { LinkedMediaPanel } from './LinkedMediaPanel';
export { ErrorModal, type ErrorDetails } from './ErrorModal'; export { ErrorModal, type ErrorDetails } from './ErrorModal';
export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal'; export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal';
export { AISuggestionsModal, type AISuggestions, type CurrentValues } from './AISuggestionsModal/AISuggestionsModal';
export { ChatPanel } from './ChatPanel'; export { ChatPanel } from './ChatPanel';
export { ImportAnalysisView } from './ImportAnalysisView'; export { ImportAnalysisView } from './ImportAnalysisView';
export { InsertModal } from './InsertModal'; export { InsertModal } from './InsertModal';