diff --git a/src/blog/BlogEditor.tsx b/src/blog/BlogEditor.tsx index 5e1aadb..58eea01 100644 --- a/src/blog/BlogEditor.tsx +++ b/src/blog/BlogEditor.tsx @@ -15,7 +15,7 @@ import { LinkNode, AutoLinkNode } from '@lexical/link'; import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'; import { HashtagNode } from '@lexical/hashtag'; -import { forwardRef, useImperativeHandle } from 'react'; +import { forwardRef, useImperativeHandle, useEffect } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { ImageNode } from './nodes/ImageNode'; @@ -23,10 +23,12 @@ import { MentionNode } from './nodes/MentionNode'; import ToolbarPlugin from './plugins/ToolbarPlugin'; import MarkdownPlugin from './plugins/MarkdownShortcutPlugin'; import ImagesPlugin from './plugins/ImagesPlugin'; -import DragDropPastePlugin from './plugins/DragDropPastePlugin'; +import DragDropPastePlugin, { setDragDropToastHandler } from './plugins/DragDropPastePlugin'; import HashtagPlugin from './plugins/HashtagPlugin'; import MentionsPlugin from './plugins/MentionsPlugin'; import editorTheme from './themes/EditorTheme'; +import { useToast } from '../hooks/useToast'; +import { Toast } from '../components/Toast'; import './styles/editor.css'; const URL_MATCHER = @@ -107,35 +109,51 @@ function EditorRefPlugin({ editorRef }: { editorRef: React.Ref }) } const BlogEditor = forwardRef((_, ref) => { + const { toasts, removeToast, success, error } = useToast(); + + // Setup toast handler for drag/drop plugin + useEffect(() => { + setDragDropToastHandler((message, type) => { + if (type === 'success') { + success(message); + } else { + error(message); + } + }); + }, [success, error]); + return ( - -
- -
- - } - placeholder={ -
Start writing your blog post...
- } - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - - - - - - - + <> + + +
+ +
+ + } + placeholder={ +
Start writing your blog post...
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + + + + + + + +
-
- + + ); }); diff --git a/src/blog/plugins/DragDropPastePlugin.tsx b/src/blog/plugins/DragDropPastePlugin.tsx index 91e44dc..86f41fb 100644 --- a/src/blog/plugins/DragDropPastePlugin.tsx +++ b/src/blog/plugins/DragDropPastePlugin.tsx @@ -7,6 +7,26 @@ import { useEffect } from 'react'; import { INSERT_IMAGE_COMMAND } from './ImagesPlugin'; import { uploadImage } from '../api'; +// Toast notification helper (global) +let showToastFn: ((message: string, type: 'success' | 'error') => void) | null = null; + +export function setDragDropToastHandler(handler: (message: string, type: 'success' | 'error') => void) { + showToastFn = handler; +} + +function showToast(message: string, type: 'success' | 'error') { + if (showToastFn) { + showToastFn(message, type); + } else { + // Fallback to console if toast not initialized + if (type === 'success') { + console.log(`✅ ${message}`); + } else { + console.error(`❌ ${message}`); + } + } +} + const ACCEPTABLE_IMAGE_TYPES = [ 'image/', 'image/heic', @@ -71,7 +91,7 @@ export default function DragDropPastePlugin(): null { }); // Show success notification - console.log('✅ Image uploaded successfully'); + showToast('Image uploaded successfully!', 'success'); } catch (error) { // Update with error editor.update(() => { @@ -86,7 +106,8 @@ export default function DragDropPastePlugin(): null { }); // Show error notification - console.error('❌ Image upload failed:', error); + const errorMessage = error instanceof Error ? error.message : 'Upload failed'; + showToast(`Image upload failed: ${errorMessage}`, 'error'); } } } diff --git a/src/components/Toast.css b/src/components/Toast.css new file mode 100644 index 0000000..f8e7f88 --- /dev/null +++ b/src/components/Toast.css @@ -0,0 +1,149 @@ +.toast-container { + position: fixed; + top: 100px; + right: 20px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 12px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-radius: 12px; + background: var(--bg-card); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + border: 2px solid; + min-width: 300px; + max-width: 400px; + pointer-events: auto; + cursor: pointer; + animation: toast-enter 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); + transition: all 0.3s ease; +} + +.toast:hover { + transform: translateX(-4px); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2); +} + +.toast-exit { + animation: toast-exit 0.3s ease forwards; +} + +@keyframes toast-enter { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes toast-exit { + from { + transform: translateX(0) scale(1); + opacity: 1; + } + to { + transform: translateX(400px) scale(0.8); + opacity: 0; + } +} + +.toast-icon { + font-size: 24px; + flex-shrink: 0; +} + +.toast-message { + flex: 1; + font-size: 15px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.4; +} + +.toast-close { + background: none; + border: none; + font-size: 24px; + color: var(--text-secondary); + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.toast-close:hover { + background: rgba(0, 0, 0, 0.1); + color: var(--text-primary); +} + +.toast-success { + border-color: #4CAF50; + background: linear-gradient(135deg, rgba(76, 175, 80, 0.1), rgba(76, 175, 80, 0.05)); +} + +.toast-error { + border-color: #F44336; + background: linear-gradient(135deg, rgba(244, 67, 54, 0.1), rgba(244, 67, 54, 0.05)); +} + +.toast-warning { + border-color: #FF9800; + background: linear-gradient(135deg, rgba(255, 152, 0, 0.1), rgba(255, 152, 0, 0.05)); +} + +.toast-info { + border-color: #2196F3; + background: linear-gradient(135deg, rgba(33, 150, 243, 0.1), rgba(33, 150, 243, 0.05)); +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .toast-container { + top: 80px; + right: 10px; + left: 10px; + } + + .toast { + min-width: unset; + max-width: unset; + } + + @keyframes toast-enter { + from { + transform: translateY(-100px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + + @keyframes toast-exit { + from { + transform: translateY(0) scale(1); + opacity: 1; + } + to { + transform: translateY(-100px) scale(0.9); + opacity: 0; + } + } +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..a904805 --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import './Toast.css'; + +export type ToastType = 'success' | 'error' | 'info' | 'warning'; + +export interface ToastMessage { + id: string; + type: ToastType; + message: string; + duration?: number; +} + +interface ToastProps { + toasts: ToastMessage[]; + onRemove: (id: string) => void; +} + +export function Toast({ toasts, onRemove }: ToastProps) { + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +} + +function ToastItem({ toast, onRemove }: { toast: ToastMessage; onRemove: (id: string) => void }) { + const [isExiting, setIsExiting] = useState(false); + + useEffect(() => { + const duration = toast.duration || 3000; + const timer = setTimeout(() => { + setIsExiting(true); + setTimeout(() => onRemove(toast.id), 300); // Wait for exit animation + }, duration); + + return () => clearTimeout(timer); + }, [toast.id, toast.duration, onRemove]); + + const getIcon = () => { + switch (toast.type) { + case 'success': + return '✅'; + case 'error': + return '❌'; + case 'warning': + return '⚠️'; + case 'info': + return 'ℹ️'; + default: + return '📢'; + } + }; + + return ( +
{ + setIsExiting(true); + setTimeout(() => onRemove(toast.id), 300); + }} + > + {getIcon()} + {toast.message} + +
+ ); +} diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts new file mode 100644 index 0000000..07f3f5b --- /dev/null +++ b/src/hooks/useToast.ts @@ -0,0 +1,49 @@ +import { useState, useCallback } from 'react'; +import type { ToastMessage, ToastType } from '../components/Toast'; + +export function useToast() { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((message: string, type: ToastType = 'info', duration?: number) => { + const id = Math.random().toString(36).substring(7); + const newToast: ToastMessage = { + id, + type, + message, + duration, + }; + + setToasts((prev) => [...prev, newToast]); + return id; + }, []); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + const success = useCallback((message: string, duration?: number) => { + return showToast(message, 'success', duration); + }, [showToast]); + + const error = useCallback((message: string, duration?: number) => { + return showToast(message, 'error', duration); + }, [showToast]); + + const warning = useCallback((message: string, duration?: number) => { + return showToast(message, 'warning', duration); + }, [showToast]); + + const info = useCallback((message: string, duration?: number) => { + return showToast(message, 'info', duration); + }, [showToast]); + + return { + toasts, + showToast, + removeToast, + success, + error, + warning, + info, + }; +} diff --git a/src/pages/CreatePost.tsx b/src/pages/CreatePost.tsx index 057889a..2928817 100644 --- a/src/pages/CreatePost.tsx +++ b/src/pages/CreatePost.tsx @@ -3,11 +3,14 @@ import { useNavigate } from 'react-router-dom'; import Layout from '../components/Layout'; import BlogEditor, { type BlogEditorRef } from '../blog/BlogEditor'; import { uploadImage, createBlogPost } from '../blog/api'; +import { Toast } from '../components/Toast'; +import { useToast } from '../hooks/useToast'; import '../App.css'; function CreatePost() { const navigate = useNavigate(); const editorRef = useRef(null); + const { toasts, removeToast, success, error } = useToast(); const [title, setTitle] = useState(''); const [coverImage, setCoverImage] = useState(null); const [coverImageKey, setCoverImageKey] = useState(''); @@ -47,10 +50,11 @@ function CreatePost() { setCoverImageKey(fileKey); setCoverImage(url); setUploadProgress(100); - console.log('✅ Cover image uploaded successfully'); - } catch (error) { - setUploadError(error instanceof Error ? error.message : 'Upload failed'); - console.error('❌ Cover image upload failed:', error); + success('Cover image uploaded successfully!'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Upload failed'; + setUploadError(errorMessage); + error(`Cover image upload failed: ${errorMessage}`); } finally { setIsUploading(false); } @@ -79,16 +83,17 @@ function CreatePost() { // Get the actual editor state as JSON const content = editorRef.current.getEditorState(); - const response = await createBlogPost(title, content, coverImageKey); + await createBlogPost(title, content, coverImageKey); - console.log('✅ Blog post created successfully:', response.postId); - alert('Blog post created successfully!'); + success('Blog post published successfully!', 2000); - // Navigate to the blog page or the created post - navigate('/blog'); - } catch (error) { - console.error('❌ Failed to create blog post:', error); - alert(error instanceof Error ? error.message : 'Failed to create blog post'); + // Navigate to the blog page after a short delay + setTimeout(() => { + navigate('/blog'); + }, 1500); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to create blog post'; + error(`Failed to publish post: ${errorMessage}`); } finally { setIsSubmitting(false); } @@ -96,6 +101,7 @@ function CreatePost() { return ( +