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

This commit is contained in:
2025-10-26 14:20:12 +08:00
parent 529af55002
commit 38ca76e2fb
6 changed files with 368 additions and 43 deletions

View File

@@ -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,7 +109,22 @@ function EditorRefPlugin({ editorRef }: { editorRef: React.Ref<BlogEditorRef> })
}
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 (
<>
<Toast toasts={toasts} onRemove={removeToast} />
<LexicalComposer initialConfig={editorConfig}>
<div className="editor-container">
<ToolbarPlugin />
@@ -136,6 +153,7 @@ const BlogEditor = forwardRef<BlogEditorRef>((_, ref) => {
</div>
</div>
</LexicalComposer>
</>
);
});

View File

@@ -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');
}
}
}

149
src/components/Toast.css Normal file
View 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
View 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
View 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,
};
}

View File

@@ -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<BlogEditorRef>(null);
const { toasts, removeToast, success, error } = useToast();
const [title, setTitle] = useState('');
const [coverImage, setCoverImage] = useState<string | null>(null);
const [coverImageKey, setCoverImageKey] = useState<string>('');
@@ -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 to the blog page after a short delay
setTimeout(() => {
navigate('/blog');
} catch (error) {
console.error('❌ Failed to create blog post:', error);
alert(error instanceof Error ? error.message : 'Failed to create blog post');
}, 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 (
<Layout currentPage="blog">
<Toast toasts={toasts} onRemove={removeToast} />
<div style={{
maxWidth: '900px',
margin: '0 auto',