diff --git a/src/pages/AdminInfoPageEditor.tsx b/src/pages/AdminInfoPageEditor.tsx index 1fe39ca..5132bab 100644 --- a/src/pages/AdminInfoPageEditor.tsx +++ b/src/pages/AdminInfoPageEditor.tsx @@ -234,6 +234,296 @@ const PlusSmallIcon = () => ( ); +// --- FAQ Answer Rich Editor --- + +const FAQ_EDITOR_EXTENSIONS = [ + StarterKit.configure({ + heading: { levels: [2, 3] }, + link: false, + underline: false, + }), + UnderlineExtension, + LinkExtension.configure({ openOnClick: false, HTMLAttributes: { class: 'link' } }), + ImageExtension.configure({ HTMLAttributes: { class: 'rounded-xl max-w-full' } }), + PlaceholderExtension.configure({ placeholder: '' }), + TextAlignExtension.configure({ types: ['heading', 'paragraph'] }), + HighlightExtension, + VideoExtension, +]; + +function FaqAnswerEditor({ value, onChange }: { value: string; onChange: (html: string) => void }) { + const { t } = useTranslation(); + const haptic = useHapticFeedback(); + const mediaRef = useRef(null); + const [uploadCount, setUploadCount] = useState(0); + const isUploading = uploadCount > 0; + const [isDragging, setIsDragging] = useState(false); + const activeUploadsRef = useRef(new Set()); + const suppressUpdate = useRef(false); + + const editor = useEditor({ + extensions: FAQ_EDITOR_EXTENSIONS, + content: value, + editorProps: { + attributes: { + class: 'prose prose-sm max-w-none min-h-[120px] p-3 focus:outline-none', + }, + }, + onUpdate: ({ editor: e }) => { + if (suppressUpdate.current) return; + const html = e.getHTML(); + const isEmpty = html === '

' || html === ''; + onChange(isEmpty ? '' : html); + }, + }); + + // Sync external value changes (e.g., reorder, locale switch) + useEffect(() => { + if (!editor || editor.isDestroyed) return; + const currentHtml = editor.getHTML(); + const normalizedCurrent = currentHtml === '

' ? '' : currentHtml; + if (normalizedCurrent !== value) { + suppressUpdate.current = true; + editor.commands.setContent(value || ''); + suppressUpdate.current = false; + } + }, [value, editor]); + + // Cleanup uploads on unmount + useEffect(() => { + const uploads = activeUploadsRef.current; + return () => { + for (const c of uploads) c.abort(); + uploads.clear(); + }; + }, []); + + const handleMediaUpload = useCallback( + async (file: File) => { + if (!editor) return; + const isImage = file.type.startsWith('image/'); + const isVideo = file.type.startsWith('video/'); + if (!isImage && !isVideo) return; + const maxSize = isImage ? 10 * 1024 * 1024 : 50 * 1024 * 1024; + if (file.size > maxSize) { + haptic.error(); + return; + } + const controller = new AbortController(); + activeUploadsRef.current.add(controller); + setUploadCount((c) => c + 1); + try { + const result = await newsApi.uploadMedia(file, controller.signal); + if (controller.signal.aborted) return; + if (!isSafeUrl(result.url)) return; + if (result.media_type === 'image') { + editor.chain().focus().setImage({ src: result.url, alt: file.name }).run(); + } else { + editor + .chain() + .focus() + .insertContent({ + type: 'video', + attrs: { src: result.url, class: 'w-full rounded-xl max-h-96' }, + }) + .run(); + } + haptic.success(); + } catch { + if (!controller.signal.aborted) haptic.error(); + } finally { + activeUploadsRef.current.delete(controller); + setUploadCount((c) => c - 1); + } + }, + [editor, haptic], + ); + + const handleMediaUploadRef = useRef(handleMediaUpload); + useEffect(() => { + handleMediaUploadRef.current = handleMediaUpload; + }, [handleMediaUpload]); + + // Register paste/drop handlers after editor is created + useEffect(() => { + if (!editor) return; + const handlePaste = (_view: unknown, event: ClipboardEvent) => { + const items = event.clipboardData?.items; + if (!items) return false; + for (const item of Array.from(items)) { + if (item.type.startsWith('image/') || item.type.startsWith('video/')) { + const file = item.getAsFile(); + if (file) { + handleMediaUploadRef.current(file); + return true; + } + } + } + return false; + }; + const handleDrop = (_view: unknown, event: DragEvent) => { + const file = event.dataTransfer?.files[0]; + if (file && (file.type.startsWith('image/') || file.type.startsWith('video/'))) { + event.preventDefault(); + handleMediaUploadRef.current(file); + return true; + } + return false; + }; + editor.setOptions({ editorProps: { ...editor.options.editorProps, handlePaste, handleDrop } }); + }, [editor]); + + const addLink = useCallback(() => { + const url = window.prompt(t('news.admin.toolbar.linkUrlPrompt')); + if (url && editor) { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return; + } catch { + return; + } + editor.chain().focus().setLink({ href: url }).run(); + } + }, [editor, t]); + + if (!editor) return null; + + return ( +
+ {/* Upload overlay */} + {isUploading && ( +
+
+ {t('news.admin.uploading')} +
+ )} + {isDragging && !isUploading && ( +
+ {t('news.admin.dropMedia')} +
+ )} + + {/* Compact toolbar */} +
+ editor.chain().focus().toggleBold().run()} + isActive={editor.isActive('bold')} + title={t('news.admin.toolbar.bold')} + > + + + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive('italic')} + title={t('news.admin.toolbar.italic')} + > + + + editor.chain().focus().toggleUnderline().run()} + isActive={editor.isActive('underline')} + title={t('news.admin.toolbar.underline')} + > + + + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive('strike')} + title={t('news.admin.toolbar.strikethrough')} + > + + +
+ editor.chain().focus().toggleHeading({ level: 2 }).run()} + isActive={editor.isActive('heading', { level: 2 })} + title={t('news.admin.toolbar.heading2')} + > + + + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive('bulletList')} + title={t('news.admin.toolbar.bulletList')} + > + + + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive('orderedList')} + title={t('news.admin.toolbar.orderedList')} + > + + + editor.chain().focus().toggleBlockquote().run()} + isActive={editor.isActive('blockquote')} + title={t('news.admin.toolbar.blockquote')} + > + + +
+ editor.chain().focus().toggleHighlight().run()} + isActive={editor.isActive('highlight')} + title={t('news.admin.toolbar.highlight')} + > + + + + + + mediaRef.current?.click()} + disabled={isUploading} + title={t('news.admin.toolbar.image')} + > + {isUploading ? ( +
+ ) : ( + + )} + +
+ + {/* Editor */} +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} + onDrop={(e) => { + setIsDragging(false); + if (e.defaultPrevented) return; + e.preventDefault(); + const file = e.dataTransfer.files[0]; + if (file) handleMediaUpload(file); + }} + > + +
+ + {/* Hidden file input */} + { + const file = e.target.files?.[0]; + if (file) handleMediaUpload(file); + e.target.value = ''; + }} + className="hidden" + aria-hidden="true" + /> +
+ ); +} + // --- FAQ Q&A Builder --- interface FaqBuilderProps { items: FaqItem[]; @@ -367,14 +657,10 @@ function FaqBuilder({ items, onChange, locale, localeLabel }: FaqBuilderProps) { -