feat: implement toast notifications for image upload and blog post creation
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 22s
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 22s
This commit is contained in:
@@ -15,7 +15,7 @@ import { LinkNode, AutoLinkNode } from '@lexical/link';
|
|||||||
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||||
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin';
|
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin';
|
||||||
import { HashtagNode } from '@lexical/hashtag';
|
import { HashtagNode } from '@lexical/hashtag';
|
||||||
import { forwardRef, useImperativeHandle } from 'react';
|
import { forwardRef, useImperativeHandle, useEffect } from 'react';
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
|
||||||
import { ImageNode } from './nodes/ImageNode';
|
import { ImageNode } from './nodes/ImageNode';
|
||||||
@@ -23,10 +23,12 @@ import { MentionNode } from './nodes/MentionNode';
|
|||||||
import ToolbarPlugin from './plugins/ToolbarPlugin';
|
import ToolbarPlugin from './plugins/ToolbarPlugin';
|
||||||
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
|
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
|
||||||
import ImagesPlugin from './plugins/ImagesPlugin';
|
import ImagesPlugin from './plugins/ImagesPlugin';
|
||||||
import DragDropPastePlugin from './plugins/DragDropPastePlugin';
|
import DragDropPastePlugin, { setDragDropToastHandler } from './plugins/DragDropPastePlugin';
|
||||||
import HashtagPlugin from './plugins/HashtagPlugin';
|
import HashtagPlugin from './plugins/HashtagPlugin';
|
||||||
import MentionsPlugin from './plugins/MentionsPlugin';
|
import MentionsPlugin from './plugins/MentionsPlugin';
|
||||||
import editorTheme from './themes/EditorTheme';
|
import editorTheme from './themes/EditorTheme';
|
||||||
|
import { useToast } from '../hooks/useToast';
|
||||||
|
import { Toast } from '../components/Toast';
|
||||||
import './styles/editor.css';
|
import './styles/editor.css';
|
||||||
|
|
||||||
const URL_MATCHER =
|
const URL_MATCHER =
|
||||||
@@ -107,35 +109,51 @@ function EditorRefPlugin({ editorRef }: { editorRef: React.Ref<BlogEditorRef> })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BlogEditor = forwardRef<BlogEditorRef>((_, ref) => {
|
const BlogEditor = forwardRef<BlogEditorRef>((_, 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 (
|
return (
|
||||||
<LexicalComposer initialConfig={editorConfig}>
|
<>
|
||||||
<div className="editor-container">
|
<Toast toasts={toasts} onRemove={removeToast} />
|
||||||
<ToolbarPlugin />
|
<LexicalComposer initialConfig={editorConfig}>
|
||||||
<div className="editor-inner">
|
<div className="editor-container">
|
||||||
<RichTextPlugin
|
<ToolbarPlugin />
|
||||||
contentEditable={
|
<div className="editor-inner">
|
||||||
<ContentEditable className="editor-input" />
|
<RichTextPlugin
|
||||||
}
|
contentEditable={
|
||||||
placeholder={
|
<ContentEditable className="editor-input" />
|
||||||
<div className="editor-placeholder">Start writing your blog post...</div>
|
}
|
||||||
}
|
placeholder={
|
||||||
ErrorBoundary={LexicalErrorBoundary}
|
<div className="editor-placeholder">Start writing your blog post...</div>
|
||||||
/>
|
}
|
||||||
<HistoryPlugin />
|
ErrorBoundary={LexicalErrorBoundary}
|
||||||
<ListPlugin />
|
/>
|
||||||
<CheckListPlugin />
|
<HistoryPlugin />
|
||||||
<LinkPlugin />
|
<ListPlugin />
|
||||||
<AutoLinkPlugin matchers={MATCHERS} />
|
<CheckListPlugin />
|
||||||
<TablePlugin />
|
<LinkPlugin />
|
||||||
<ImagesPlugin />
|
<AutoLinkPlugin matchers={MATCHERS} />
|
||||||
<DragDropPastePlugin />
|
<TablePlugin />
|
||||||
<HashtagPlugin />
|
<ImagesPlugin />
|
||||||
<MentionsPlugin />
|
<DragDropPastePlugin />
|
||||||
<MarkdownPlugin />
|
<HashtagPlugin />
|
||||||
<EditorRefPlugin editorRef={ref} />
|
<MentionsPlugin />
|
||||||
|
<MarkdownPlugin />
|
||||||
|
<EditorRefPlugin editorRef={ref} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</LexicalComposer>
|
||||||
</LexicalComposer>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,26 @@ import { useEffect } from 'react';
|
|||||||
import { INSERT_IMAGE_COMMAND } from './ImagesPlugin';
|
import { INSERT_IMAGE_COMMAND } from './ImagesPlugin';
|
||||||
import { uploadImage } from '../api';
|
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 = [
|
const ACCEPTABLE_IMAGE_TYPES = [
|
||||||
'image/',
|
'image/',
|
||||||
'image/heic',
|
'image/heic',
|
||||||
@@ -71,7 +91,7 @@ export default function DragDropPastePlugin(): null {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Show success notification
|
// Show success notification
|
||||||
console.log('✅ Image uploaded successfully');
|
showToast('Image uploaded successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Update with error
|
// Update with error
|
||||||
editor.update(() => {
|
editor.update(() => {
|
||||||
@@ -86,7 +106,8 @@ export default function DragDropPastePlugin(): null {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Show error notification
|
// Show error notification
|
||||||
console.error('❌ Image upload failed:', error);
|
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
||||||
|
showToast(`Image upload failed: ${errorMessage}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
149
src/components/Toast.css
Normal file
149
src/components/Toast.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/components/Toast.tsx
Normal file
82
src/components/Toast.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="toast-container">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastItem
|
||||||
|
key={toast.id}
|
||||||
|
toast={toast}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={`toast toast-${toast.type} ${isExiting ? 'toast-exit' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsExiting(true);
|
||||||
|
setTimeout(() => onRemove(toast.id), 300);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="toast-icon">{getIcon()}</span>
|
||||||
|
<span className="toast-message">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
className="toast-close"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsExiting(true);
|
||||||
|
setTimeout(() => onRemove(toast.id), 300);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/hooks/useToast.ts
Normal file
49
src/hooks/useToast.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import type { ToastMessage, ToastType } from '../components/Toast';
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,11 +3,14 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import BlogEditor, { type BlogEditorRef } from '../blog/BlogEditor';
|
import BlogEditor, { type BlogEditorRef } from '../blog/BlogEditor';
|
||||||
import { uploadImage, createBlogPost } from '../blog/api';
|
import { uploadImage, createBlogPost } from '../blog/api';
|
||||||
|
import { Toast } from '../components/Toast';
|
||||||
|
import { useToast } from '../hooks/useToast';
|
||||||
import '../App.css';
|
import '../App.css';
|
||||||
|
|
||||||
function CreatePost() {
|
function CreatePost() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const editorRef = useRef<BlogEditorRef>(null);
|
const editorRef = useRef<BlogEditorRef>(null);
|
||||||
|
const { toasts, removeToast, success, error } = useToast();
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [coverImage, setCoverImage] = useState<string | null>(null);
|
const [coverImage, setCoverImage] = useState<string | null>(null);
|
||||||
const [coverImageKey, setCoverImageKey] = useState<string>('');
|
const [coverImageKey, setCoverImageKey] = useState<string>('');
|
||||||
@@ -47,10 +50,11 @@ function CreatePost() {
|
|||||||
setCoverImageKey(fileKey);
|
setCoverImageKey(fileKey);
|
||||||
setCoverImage(url);
|
setCoverImage(url);
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
console.log('✅ Cover image uploaded successfully');
|
success('Cover image uploaded successfully!');
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
setUploadError(error instanceof Error ? error.message : 'Upload failed');
|
const errorMessage = err instanceof Error ? err.message : 'Upload failed';
|
||||||
console.error('❌ Cover image upload failed:', error);
|
setUploadError(errorMessage);
|
||||||
|
error(`Cover image upload failed: ${errorMessage}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
@@ -79,16 +83,17 @@ function CreatePost() {
|
|||||||
// Get the actual editor state as JSON
|
// Get the actual editor state as JSON
|
||||||
const content = editorRef.current.getEditorState();
|
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);
|
success('Blog post published successfully!', 2000);
|
||||||
alert('Blog post created successfully!');
|
|
||||||
|
|
||||||
// Navigate to the blog page or the created post
|
// Navigate to the blog page after a short delay
|
||||||
navigate('/blog');
|
setTimeout(() => {
|
||||||
} catch (error) {
|
navigate('/blog');
|
||||||
console.error('❌ Failed to create blog post:', error);
|
}, 1500);
|
||||||
alert(error instanceof Error ? error.message : 'Failed to create blog post');
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to create blog post';
|
||||||
|
error(`Failed to publish post: ${errorMessage}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -96,6 +101,7 @@ function CreatePost() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout currentPage="blog">
|
<Layout currentPage="blog">
|
||||||
|
<Toast toasts={toasts} onRemove={removeToast} />
|
||||||
<div style={{
|
<div style={{
|
||||||
maxWidth: '900px',
|
maxWidth: '900px',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
|
|||||||
Reference in New Issue
Block a user