From 99e74e3b23bf412653c21f2a6baaa8f3bb5d671a Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 15 Feb 2026 10:23:49 +0100 Subject: [PATCH] feat: ai suggestions with modal dialog --- .../AISuggestionsModal/AISuggestionsModal.css | 275 ++++++++++++++++++ .../AISuggestionsModal/AISuggestionsModal.tsx | 222 ++++++++++++++ src/renderer/components/Editor/Editor.tsx | 59 +++- src/renderer/components/index.ts | 1 + 4 files changed, 544 insertions(+), 13 deletions(-) create mode 100644 src/renderer/components/AISuggestionsModal/AISuggestionsModal.css create mode 100644 src/renderer/components/AISuggestionsModal/AISuggestionsModal.tsx diff --git a/src/renderer/components/AISuggestionsModal/AISuggestionsModal.css b/src/renderer/components/AISuggestionsModal/AISuggestionsModal.css new file mode 100644 index 0000000..f429098 --- /dev/null +++ b/src/renderer/components/AISuggestionsModal/AISuggestionsModal.css @@ -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; +} diff --git a/src/renderer/components/AISuggestionsModal/AISuggestionsModal.tsx b/src/renderer/components/AISuggestionsModal/AISuggestionsModal.tsx new file mode 100644 index 0000000..2e537df --- /dev/null +++ b/src/renderer/components/AISuggestionsModal/AISuggestionsModal.tsx @@ -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) => void; + onClose: () => void; +} + +export const AISuggestionsModal: React.FC = ({ + 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 = {}; + 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 ( +
+
+
+

AI Image Analysis

+ {!isLoading && ( + + )} +
+ +
+ {isLoading && ( +
+
+

Analyzing image...

+
+ )} + + {error && !isLoading && ( +
+ ⚠️ + {error} +
+ )} + + {!isLoading && !error && hasAnySuggestion && ( +
+

+ Select which AI-generated values to apply. Existing values are preserved by default. +

+ + {suggestions?.title && ( +
+ +
+
+ Title + {currentValues.title && ( + + (has existing value) + + )} +
+
{suggestions.title}
+ {currentValues.title && ( +
+ Current: {currentValues.title} +
+ )} +
+
+ )} + + {suggestions?.alt && ( +
+ +
+
+ Alt Text + {currentValues.alt && ( + + (has existing value) + + )} +
+
{suggestions.alt}
+ {currentValues.alt && ( +
+ Current: {currentValues.alt} +
+ )} +
+
+ )} + + {suggestions?.caption && ( +
+ +
+
+ Caption + {currentValues.caption && ( + + (has existing value) + + )} +
+
{suggestions.caption}
+ {currentValues.caption && ( +
+ Current: {currentValues.caption} +
+ )} +
+
+ )} +
+ )} + + {!isLoading && !error && !hasAnySuggestion && suggestions && ( +
+ No suggestions were generated for this image. +
+ )} +
+ +
+ {isLoading ? ( + + ) : ( + <> + + {hasAnySuggestion && ( + + )} + + )} +
+
+
+ ); +}; diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 698cc18..af179a1 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -16,6 +16,7 @@ import { ImportAnalysisView } from '../ImportAnalysisView'; import { AutoSaveManager } from '../../utils'; import { parseMacros, getMacro } from '../../macros/registry'; import { InsertModal } from '../InsertModal'; +import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal'; import './Editor.css'; /** Get display name for media: prefer title over originalName */ @@ -1443,10 +1444,15 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => { // Quick action menu state const [showQuickActions, setShowQuickActions] = useState(false); - const [isAnalyzing, setIsAnalyzing] = useState(false); const [projectLanguage, setProjectLanguage] = useState('en'); const quickActionsRef = useRef(null); + // AI suggestions modal state + const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [aiSuggestions, setAISuggestions] = useState(null); + const [aiError, setAIError] = useState(undefined); + // Load project language setting useEffect(() => { window.electronAPI?.meta.getProjectMetadata().then(metadata => { @@ -1474,33 +1480,49 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => { if (!item || isAnalyzing) return; setShowQuickActions(false); + setShowAISuggestionsModal(true); setIsAnalyzing(true); + setAISuggestions(null); + setAIError(undefined); try { const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage); if (result?.success) { - if (result.title) setTitle(result.title); - if (result.alt) setAlt(result.alt); - if (result.caption) setCaption(result.caption); - showToast.success('AI analysis complete'); - } else { - showErrorModal({ - title: 'AI Analysis Failed', - message: result?.error || 'Failed to analyze image', + setAISuggestions({ + title: result.title, + alt: result.alt, + caption: result.caption, }); + } else { + setAIError(result?.error || 'Failed to analyze image'); } } catch (error) { console.error('Failed to analyze image:', error); - showErrorModal({ - title: 'AI Analysis Error', - message: (error as Error).message || 'Failed to analyze image', - }); + setAIError((error as Error).message || 'Failed to analyze image'); } finally { setIsAnalyzing(false); } }; + // Handle applying AI suggestions + const handleApplyAISuggestions = (values: Partial) => { + 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 useEffect(() => { const loadLinkedPosts = async () => { @@ -1875,6 +1897,17 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => { + + {/* AI Suggestions Modal */} + ); }; diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index a8bd8e8..aa982f3 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -18,6 +18,7 @@ export { PostLinks } from './PostLinks'; export { LinkedMediaPanel } from './LinkedMediaPanel'; export { ErrorModal, type ErrorDetails } from './ErrorModal'; export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal'; +export { AISuggestionsModal, type AISuggestions, type CurrentValues } from './AISuggestionsModal/AISuggestionsModal'; export { ChatPanel } from './ChatPanel'; export { ImportAnalysisView } from './ImportAnalysisView'; export { InsertModal } from './InsertModal';