feat: add toolbar styles and functionality for blog editor
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 18s

feat: implement EditorTheme for consistent styling across editor components

feat: define types for blog-related operations including image uploads and post creation

feat: create DropdownColorPicker component for color selection in blog editor

feat: implement ImageResizer component for resizing images in the blog editor

feat: add export and import functionality for blog posts in JSON format

feat: update main application routes to include CreatePost page

feat: enhance Blog page with a button to navigate to CreatePost

feat: implement CreatePost page with title, cover image upload, and content editor
This commit is contained in:
2025-10-25 21:03:58 +08:00
parent 4829c53355
commit e299694f22
26 changed files with 4095 additions and 1 deletions

120
src/blog/BlogEditor.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { CodeNode, CodeHighlightNode } from '@lexical/code';
import { ListItemNode, ListNode } from '@lexical/list';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin';
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table';
import { TablePlugin } from '@lexical/react/LexicalTablePlugin';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin';
import { HashtagNode } from '@lexical/hashtag';
import { ImageNode } from './nodes/ImageNode';
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 HashtagPlugin from './plugins/HashtagPlugin';
import MentionsPlugin from './plugins/MentionsPlugin';
import editorTheme from './themes/EditorTheme';
import './styles/editor.css';
const URL_MATCHER =
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
const EMAIL_MATCHER =
/(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/;
const MATCHERS = [
(text: string) => {
const match = URL_MATCHER.exec(text);
if (match === null) {
return null;
}
const fullMatch = match[0];
return {
index: match.index,
length: fullMatch.length,
text: fullMatch,
url: fullMatch.startsWith('http') ? fullMatch : `https://${fullMatch}`,
};
},
(text: string) => {
const match = EMAIL_MATCHER.exec(text);
if (match === null) {
return null;
}
const fullMatch = match[0];
return {
index: match.index,
length: fullMatch.length,
text: fullMatch,
url: `mailto:${fullMatch}`,
};
},
];
const editorConfig = {
namespace: 'BlogEditor',
theme: editorTheme,
onError(error: Error) {
console.error(error);
},
nodes: [
HeadingNode,
QuoteNode,
CodeNode,
CodeHighlightNode,
ListNode,
ListItemNode,
HorizontalRuleNode,
TableNode,
TableRowNode,
TableCellNode,
LinkNode,
AutoLinkNode,
ImageNode,
HashtagNode,
MentionNode,
],
};
export default function BlogEditor() {
return (
<LexicalComposer initialConfig={editorConfig}>
<div className="editor-container">
<ToolbarPlugin />
<div className="editor-inner">
<RichTextPlugin
contentEditable={
<ContentEditable className="editor-input" />
}
placeholder={
<div className="editor-placeholder">Start writing your blog post...</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<ListPlugin />
<CheckListPlugin />
<LinkPlugin />
<AutoLinkPlugin matchers={MATCHERS} />
<TablePlugin />
<ImagesPlugin />
<DragDropPastePlugin />
<HashtagPlugin />
<MentionsPlugin />
<MarkdownPlugin />
</div>
</div>
</LexicalComposer>
);
}

148
src/blog/README.md Normal file
View File

@@ -0,0 +1,148 @@
# Blog Module
This module contains all the blog-related functionality for creating and managing blog posts.
## Features
### ✍️ Rich Text Editor
- Custom Lexical-based editor with full formatting support
- Markdown shortcuts
- Image upload with progress tracking
- Hashtags and mentions
- Tables, lists, code blocks, and more
### 📷 Image Upload
- Direct upload to S3 using presigned URLs
- Real-time upload progress indicator
- Error handling with user notifications
- Automatic image key storage for reference
### 📝 Post Creation
- Title and cover image
- Rich text content editing
- Draft and publish workflow
- Validation before submission
## Structure
```
src/blog/
├── BlogEditor.tsx # Main editor component
├── api.ts # API functions for blog operations
├── types.ts # TypeScript type definitions
├── index.ts # Module exports
├── nodes/ # Custom Lexical nodes
│ ├── ImageNode.tsx # Image node with upload support
│ ├── ImageComponent.tsx # Image component with progress UI
│ ├── ImageResizer.tsx # Image resizing functionality
│ └── MentionNode.ts # Mention node
├── plugins/ # Lexical editor plugins
│ ├── ImagesPlugin.tsx # Image insertion plugin
│ ├── DragDropPastePlugin.tsx # Image upload on drag/drop
│ ├── ToolbarPlugin.tsx # Editor toolbar
│ ├── MarkdownShortcutPlugin.tsx # Markdown shortcuts
│ ├── HashtagPlugin.tsx # Hashtag support
│ └── MentionsPlugin.tsx # Mentions support
├── styles/ # Editor styles
│ ├── editor.css
│ └── toolbar.css
├── themes/ # Editor themes
│ └── EditorTheme.ts
├── ui/ # UI components
│ ├── DropdownColorPicker.tsx
│ └── DropdownColorPicker.css
└── utils/ # Utility functions
└── exportImport.ts
```
## Usage
### Creating a New Post
```tsx
import { CreatePost } from './pages/CreatePost';
// The CreatePost component provides a complete UI for:
// 1. Entering post title
// 2. Uploading cover image
// 3. Writing content with rich text editor
// 4. Publishing the post
```
### Using the Blog Editor
```tsx
import { BlogEditor } from './blog';
function MyComponent() {
return <BlogEditor />;
}
```
### API Functions
```typescript
import { uploadImage, createBlogPost } from './blog/api';
// Upload an image
const { fileKey, url } = await uploadImage(file, (progress) => {
console.log(`Upload progress: ${progress}%`);
});
// Create a blog post
const { postId } = await createBlogPost(title, content, coverImageKey);
```
## Image Upload Flow
1. **User Action**: User drops/pastes image or uploads cover image
2. **Preview**: Image preview shown immediately with local data URL
3. **Get Upload URL**: Request presigned URL from backend API
4. **Upload to S3**: Upload file directly to S3 with progress tracking
5. **Get Download URL**: Request presigned download URL
6. **Update Node**: Update image node with final URL and fileKey
7. **Notifications**: Show success/error messages to user
## API Endpoints
### File Upload
```
POST /api/blog/file/upload
Body: { fileName: string }
Response: { url: string, fileKey: string, expireAt: number }
```
### File Download
```
POST /api/blog/file/download
Body: { fileKey: string }
Response: { url: string, expireAt: number }
```
### Create Post
```
POST /api/blog/post/create
Body: { title: string, content: string, coverImageKey: string }
Response: { postId: string }
```
## Features to Implement
- [ ] List all blog posts
- [ ] View single blog post
- [ ] Edit blog post
- [ ] Delete blog post
- [ ] Add tags/categories
- [ ] Comments system
- [ ] Like/reaction system
- [ ] Search functionality
- [ ] Pagination
- [ ] Draft management
- [ ] Scheduled publishing
## Notes
- All images are stored with their S3 file keys in the editor state
- Upload progress is tracked in real-time and displayed to users
- Error handling includes both API errors and upload failures
- The editor state can be exported/imported for draft saving

128
src/blog/api.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* Blog API functions
*/
import { apiRequest } from '../utils/api';
import type {
UploadPresignedURLResponse,
DownloadPresignedURLResponse,
CreatePostResponse,
} from './types';
const API_BASE = '/api/blog';
/**
* Get presigned URL for file upload
*/
export async function getUploadPresignedURL(fileName: string): Promise<UploadPresignedURLResponse> {
const response = await apiRequest(`${API_BASE}/file/upload`, {
method: 'POST',
body: JSON.stringify({ fileName }),
});
if (!response.ok) {
throw new Error(`Failed to get upload URL: ${response.statusText}`);
}
return response.json();
}
/**
* Get presigned URL for file download
*/
export async function getDownloadPresignedURL(fileKey: string): Promise<DownloadPresignedURLResponse> {
const response = await apiRequest(`${API_BASE}/file/download`, {
method: 'POST',
body: JSON.stringify({ fileKey }),
});
if (!response.ok) {
throw new Error(`Failed to get download URL: ${response.statusText}`);
}
return response.json();
}
/**
* Upload file to S3 using presigned URL
*/
export async function uploadFileToS3(
url: string,
file: File,
onProgress?: (progress: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// Track upload progress
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && onProgress) {
const progress = (event.loaded / event.total) * 100;
onProgress(progress);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Upload aborted'));
});
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
});
}
/**
* Complete image upload workflow
*/
export async function uploadImage(
file: File,
onProgress?: (progress: number) => void
): Promise<{ fileKey: string; url: string }> {
// Step 1: Get presigned URL
const { url: uploadUrl, fileKey } = await getUploadPresignedURL(file.name);
// Step 2: Upload file to S3
await uploadFileToS3(uploadUrl, file, onProgress);
// Step 3: Get download URL
const { url: downloadUrl } = await getDownloadPresignedURL(fileKey);
return { fileKey, url: downloadUrl };
}
/**
* Create a new blog post
*/
export async function createBlogPost(
title: string,
content: string,
coverImageKey: string
): Promise<CreatePostResponse> {
const response = await apiRequest(`${API_BASE}/post/create`, {
method: 'POST',
body: JSON.stringify({
title,
content,
coverImageKey,
}),
});
if (!response.ok) {
throw new Error(`Failed to create post: ${response.statusText}`);
}
return response.json();
}

7
src/blog/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* Blog module exports
*/
export { default as BlogEditor } from './BlogEditor';
export * from './types';
export * from './api';

View File

@@ -0,0 +1,335 @@
import type { NodeKey } from 'lexical';
import type { JSX } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
import { mergeRegister } from '@lexical/utils';
import {
$getNodeByKey,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
DRAGSTART_COMMAND,
KEY_ESCAPE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import ImageResizer from './ImageResizer';
import { $isImageNode } from './ImageNode';
type ImageStatus =
| { error: true }
| { error: false; width: number; height: number };
const imageCache = new Map<string, Promise<ImageStatus> | ImageStatus>();
function useSuspenseImage(src: string): ImageStatus {
let cached = imageCache.get(src);
if (cached && 'error' in cached && typeof cached.error === 'boolean') {
return cached;
} else if (!cached) {
cached = new Promise<ImageStatus>((resolve) => {
const img = new Image();
img.src = src;
img.onload = () =>
resolve({
error: false,
height: img.naturalHeight,
width: img.naturalWidth,
});
img.onerror = () => resolve({ error: true });
}).then((rval) => {
imageCache.set(src, rval);
return rval;
});
imageCache.set(src, cached);
throw cached;
}
throw cached;
}
function LazyImage({
altText,
className,
imageRef,
src,
width,
height,
maxWidth,
onError,
}: {
altText: string;
className: string | null;
height: 'inherit' | number;
imageRef: { current: null | HTMLImageElement };
maxWidth: number;
src: string;
width: 'inherit' | number;
onError: () => void;
}): JSX.Element {
const status = useSuspenseImage(src);
useEffect(() => {
if (status.error) {
onError();
}
}, [status.error, onError]);
if (status.error) {
return (
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect fill='%23f0f0f0' width='200' height='200'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-family='sans-serif'%3EImage not found%3C/text%3E%3C/svg%3E"
style={{
height: 200,
opacity: 0.3,
width: 200,
}}
draggable="false"
alt="Broken image"
/>
);
}
return (
<img
className={className || undefined}
src={src}
alt={altText}
ref={imageRef}
style={{
height: height === 'inherit' ? 'auto' : height,
maxWidth,
width: width === 'inherit' ? '100%' : width,
}}
onError={onError}
draggable="false"
/>
);
}
export default function ImageComponent({
src,
altText,
nodeKey,
width,
height,
maxWidth,
resizable,
fileKey: _fileKey, // Stored but not used in component
uploadProgress,
uploadError,
}: {
altText: string;
height: 'inherit' | number;
maxWidth: number;
nodeKey: NodeKey;
resizable: boolean;
src: string;
width: 'inherit' | number;
fileKey?: string;
uploadProgress?: number;
uploadError?: string;
}): JSX.Element {
const imageRef = useRef<null | HTMLImageElement>(null);
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey);
const [isResizing, setIsResizing] = useState<boolean>(false);
const [editor] = useLexicalComposerContext();
const [isLoadError, setIsLoadError] = useState<boolean>(false);
const $onEscape = useCallback(
() => {
if (isSelected) {
$setSelection(null);
editor.update(() => {
setSelected(true);
const parentRootElement = editor.getRootElement();
if (parentRootElement !== null) {
parentRootElement.focus();
}
});
return true;
}
return false;
},
[editor, isSelected, setSelected],
);
const onClick = useCallback(
(payload: MouseEvent) => {
const event = payload;
if (isResizing) {
return true;
}
if (event.target === imageRef.current) {
if (event.shiftKey) {
setSelected(!isSelected);
} else {
clearSelection();
setSelected(true);
}
return true;
}
return false;
},
[isResizing, isSelected, setSelected, clearSelection],
);
useEffect(() => {
return mergeRegister(
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
DRAGSTART_COMMAND,
(event) => {
if (event.target === imageRef.current) {
event.preventDefault();
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<MouseEvent>(
CLICK_COMMAND,
onClick,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
$onEscape,
COMMAND_PRIORITY_LOW,
),
);
}, [clearSelection, editor, isResizing, isSelected, onClick, $onEscape]);
const onResizeEnd = (
nextWidth: 'inherit' | number,
nextHeight: 'inherit' | number,
) => {
setTimeout(() => {
setIsResizing(false);
}, 200);
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isImageNode(node)) {
node.setWidthAndHeight(nextWidth, nextHeight);
}
});
};
const onResizeStart = () => {
setIsResizing(true);
};
const draggable = isSelected && !isResizing;
const isFocused = isSelected || isResizing;
// Calculate if upload is in progress
const isUploading = uploadProgress !== undefined && uploadProgress < 100 && !uploadError;
return (
<>
<div draggable={draggable} style={{ position: 'relative', display: 'inline-block' }}>
{isLoadError ? (
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect fill='%23f0f0f0' width='200' height='200'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-family='sans-serif'%3EImage not found%3C/text%3E%3C/svg%3E"
style={{
height: 200,
opacity: 0.3,
width: 200,
}}
draggable="false"
alt="Broken image"
/>
) : (
<LazyImage
className={isFocused ? 'focused' : null}
src={src}
altText={altText}
imageRef={imageRef}
width={width}
height={height}
maxWidth={maxWidth}
onError={() => setIsLoadError(true)}
/>
)}
{/* Upload Progress Bar */}
{isUploading && (
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
background: 'rgba(0, 0, 0, 0.7)',
padding: '8px',
}}>
<div style={{
background: 'rgba(255, 255, 255, 0.2)',
borderRadius: '4px',
overflow: 'hidden',
height: '4px',
}}>
<div style={{
background: 'var(--accent-color, #4CAF50)',
height: '100%',
width: `${uploadProgress || 0}%`,
transition: 'width 0.3s ease',
}} />
</div>
<div style={{
color: 'white',
fontSize: '12px',
marginTop: '4px',
textAlign: 'center',
}}>
Uploading... {Math.round(uploadProgress || 0)}%
</div>
</div>
)}
{/* Upload Error */}
{uploadError && (
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
background: 'rgba(244, 67, 54, 0.9)',
padding: '8px',
color: 'white',
fontSize: '12px',
textAlign: 'center',
}}>
{uploadError}
</div>
)}
</div>
{resizable && isSelected && isFocused && (
<ImageResizer
editor={editor}
imageRef={imageRef}
maxWidth={maxWidth}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
/>
)}
</>
);
}

View File

@@ -0,0 +1,276 @@
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
import type { JSX } from 'react';
import {
$applyNodeReplacement,
DecoratorNode,
} from 'lexical';
import { Suspense, lazy } from 'react';
const ImageComponent = lazy(() => import('./ImageComponent'));
export interface ImagePayload {
altText: string;
height?: number;
key?: NodeKey;
maxWidth?: number;
src: string;
width?: number;
fileKey?: string; // S3 file key
uploadProgress?: number; // Upload progress (0-100)
uploadError?: string; // Upload error message
}
function $convertImageElement(domNode: Node): null | DOMConversionOutput {
const img = domNode as HTMLImageElement;
const src = img.getAttribute('src');
if (!src || src.startsWith('file:///')) {
return null;
}
const { alt: altText, width, height } = img;
const fileKey = img.getAttribute('data-file-key') || undefined;
const node = $createImageNode({ altText, height, src, width, fileKey });
return { node };
}
export type SerializedImageNode = Spread<
{
altText: string;
height?: number;
maxWidth: number;
src: string;
width?: number;
fileKey?: string;
uploadProgress?: number;
uploadError?: string;
},
SerializedLexicalNode
>;
export class ImageNode extends DecoratorNode<JSX.Element> {
__src: string;
__altText: string;
__width: 'inherit' | number;
__height: 'inherit' | number;
__maxWidth: number;
__fileKey?: string;
__uploadProgress?: number;
__uploadError?: string;
static getType(): string {
return 'image';
}
static clone(node: ImageNode): ImageNode {
return new ImageNode(
node.__src,
node.__altText,
node.__maxWidth,
node.__width,
node.__height,
node.__fileKey,
node.__uploadProgress,
node.__uploadError,
node.__key,
);
}
static importJSON(serializedNode: SerializedImageNode): ImageNode {
const { altText, height, width, maxWidth, src, fileKey, uploadProgress, uploadError } = serializedNode;
return $createImageNode({
altText,
height,
maxWidth,
src,
width,
fileKey,
uploadProgress,
uploadError,
});
}
exportDOM(): DOMExportOutput {
const element = document.createElement('img');
element.setAttribute('src', this.__src);
element.setAttribute('alt', this.__altText);
if (this.__fileKey) {
element.setAttribute('data-file-key', this.__fileKey);
}
if (this.__width !== 'inherit') {
element.setAttribute('width', this.__width.toString());
}
if (this.__height !== 'inherit') {
element.setAttribute('height', this.__height.toString());
}
return { element };
}
static importDOM(): DOMConversionMap | null {
return {
img: () => ({
conversion: $convertImageElement,
priority: 0,
}),
};
}
constructor(
src: string,
altText: string,
maxWidth: number,
width?: 'inherit' | number,
height?: 'inherit' | number,
fileKey?: string,
uploadProgress?: number,
uploadError?: string,
key?: NodeKey,
) {
super(key);
this.__src = src;
this.__altText = altText;
this.__maxWidth = maxWidth;
this.__width = width || 'inherit';
this.__height = height || 'inherit';
this.__fileKey = fileKey;
this.__uploadProgress = uploadProgress;
this.__uploadError = uploadError;
}
exportJSON(): SerializedImageNode {
return {
altText: this.getAltText(),
height: this.__height === 'inherit' ? 0 : this.__height,
maxWidth: this.__maxWidth,
src: this.getSrc(),
type: 'image',
version: 1,
width: this.__width === 'inherit' ? 0 : this.__width,
fileKey: this.__fileKey,
uploadProgress: this.__uploadProgress,
uploadError: this.__uploadError,
};
}
setWidthAndHeight(
width: 'inherit' | number,
height: 'inherit' | number,
): void {
const writable = this.getWritable();
writable.__width = width;
writable.__height = height;
}
setUploadProgress(progress: number): void {
const writable = this.getWritable();
writable.__uploadProgress = progress;
}
setUploadError(error: string | undefined): void {
const writable = this.getWritable();
writable.__uploadError = error;
}
setFileKey(fileKey: string): void {
const writable = this.getWritable();
writable.__fileKey = fileKey;
}
setSrc(src: string): void {
const writable = this.getWritable();
writable.__src = src;
}
createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const theme = config.theme;
const className = theme.image;
if (className !== undefined) {
span.className = className;
}
return span;
}
updateDOM(): false {
return false;
}
getSrc(): string {
return this.__src;
}
getAltText(): string {
return this.__altText;
}
getFileKey(): string | undefined {
return this.__fileKey;
}
getUploadProgress(): number | undefined {
return this.__uploadProgress;
}
getUploadError(): string | undefined {
return this.__uploadError;
}
decorate(): JSX.Element {
return (
<Suspense fallback={null}>
<ImageComponent
src={this.__src}
altText={this.__altText}
width={this.__width}
height={this.__height}
maxWidth={this.__maxWidth}
nodeKey={this.getKey()}
resizable={true}
fileKey={this.__fileKey}
uploadProgress={this.__uploadProgress}
uploadError={this.__uploadError}
/>
</Suspense>
);
}
}
export function $createImageNode({
altText,
height,
maxWidth = 800,
src,
width,
key,
fileKey,
uploadProgress,
uploadError,
}: ImagePayload): ImageNode {
return $applyNodeReplacement(
new ImageNode(
src,
altText,
maxWidth,
width,
height,
fileKey,
uploadProgress,
uploadError,
key,
),
);
}
export function $isImageNode(
node: LexicalNode | null | undefined,
): node is ImageNode {
return node instanceof ImageNode;
}

View File

@@ -0,0 +1,292 @@
import type { LexicalEditor } from 'lexical';
import type { JSX } from 'react';
import { calculateZoomLevel } from '@lexical/utils';
import { useRef } from 'react';
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
const Direction = {
east: 1 << 0,
north: 1 << 3,
south: 1 << 1,
west: 1 << 2,
};
export default function ImageResizer({
onResizeStart,
onResizeEnd,
imageRef,
maxWidth,
editor,
}: {
editor: LexicalEditor;
imageRef: { current: null | HTMLElement };
maxWidth?: number;
onResizeEnd: (width: 'inherit' | number, height: 'inherit' | number) => void;
onResizeStart: () => void;
}): JSX.Element {
const controlWrapperRef = useRef<HTMLDivElement>(null);
const userSelect = useRef({
priority: '',
value: 'default',
});
const positioningRef = useRef<{
currentHeight: 'inherit' | number;
currentWidth: 'inherit' | number;
direction: number;
isResizing: boolean;
ratio: number;
startHeight: number;
startWidth: number;
startX: number;
startY: number;
}>({
currentHeight: 0,
currentWidth: 0,
direction: 0,
isResizing: false,
ratio: 0,
startHeight: 0,
startWidth: 0,
startX: 0,
startY: 0,
});
const editorRootElement = editor.getRootElement();
const maxWidthContainer = maxWidth
? maxWidth
: editorRootElement !== null
? editorRootElement.getBoundingClientRect().width - 20
: 100;
const maxHeightContainer =
editorRootElement !== null
? editorRootElement.getBoundingClientRect().height - 20
: 100;
const minWidth = 100;
const minHeight = 100;
const setStartCursor = (direction: number) => {
const ew = direction === Direction.east || direction === Direction.west;
const ns = direction === Direction.north || direction === Direction.south;
const nwse =
(direction & Direction.north && direction & Direction.west) ||
(direction & Direction.south && direction & Direction.east);
const cursorDir = ew ? 'ew' : ns ? 'ns' : nwse ? 'nwse' : 'nesw';
if (editorRootElement !== null) {
editorRootElement.style.setProperty(
'cursor',
`${cursorDir}-resize`,
'important',
);
}
if (document.body !== null) {
document.body.style.setProperty(
'cursor',
`${cursorDir}-resize`,
'important',
);
userSelect.current.value = document.body.style.getPropertyValue(
'-webkit-user-select',
);
userSelect.current.priority = document.body.style.getPropertyPriority(
'-webkit-user-select',
);
document.body.style.setProperty(
'-webkit-user-select',
`none`,
'important',
);
}
};
const setEndCursor = () => {
if (editorRootElement !== null) {
editorRootElement.style.setProperty('cursor', 'text');
}
if (document.body !== null) {
document.body.style.setProperty('cursor', 'default');
document.body.style.setProperty(
'-webkit-user-select',
userSelect.current.value,
userSelect.current.priority,
);
}
};
const handlePointerDown = (
event: React.PointerEvent<HTMLDivElement>,
direction: number,
) => {
if (!editor.isEditable()) {
return;
}
const image = imageRef.current;
const controlWrapper = controlWrapperRef.current;
if (image !== null && controlWrapper !== null) {
event.preventDefault();
const { width, height } = image.getBoundingClientRect();
const zoom = calculateZoomLevel(image);
const positioning = positioningRef.current;
positioning.startWidth = width;
positioning.startHeight = height;
positioning.ratio = width / height;
positioning.currentWidth = width;
positioning.currentHeight = height;
positioning.startX = event.clientX / zoom;
positioning.startY = event.clientY / zoom;
positioning.isResizing = true;
positioning.direction = direction;
setStartCursor(direction);
onResizeStart();
controlWrapper.classList.add('image-control-wrapper--resizing');
image.style.height = `${height}px`;
image.style.width = `${width}px`;
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
}
};
const handlePointerMove = (event: PointerEvent) => {
const image = imageRef.current;
const positioning = positioningRef.current;
const isHorizontal =
positioning.direction & (Direction.east | Direction.west);
const isVertical =
positioning.direction & (Direction.south | Direction.north);
if (image !== null && positioning.isResizing) {
const zoom = calculateZoomLevel(image);
if (isHorizontal && isVertical) {
let diff = Math.floor(positioning.startX - event.clientX / zoom);
diff = positioning.direction & Direction.east ? -diff : diff;
const width = clamp(
positioning.startWidth + diff,
minWidth,
maxWidthContainer,
);
const height = width / positioning.ratio;
image.style.width = `${width}px`;
image.style.height = `${height}px`;
positioning.currentHeight = height;
positioning.currentWidth = width;
} else if (isVertical) {
let diff = Math.floor(positioning.startY - event.clientY / zoom);
diff = positioning.direction & Direction.south ? -diff : diff;
const height = clamp(
positioning.startHeight + diff,
minHeight,
maxHeightContainer,
);
image.style.height = `${height}px`;
positioning.currentHeight = height;
} else {
let diff = Math.floor(positioning.startX - event.clientX / zoom);
diff = positioning.direction & Direction.east ? -diff : diff;
const width = clamp(
positioning.startWidth + diff,
minWidth,
maxWidthContainer,
);
image.style.width = `${width}px`;
positioning.currentWidth = width;
}
}
};
const handlePointerUp = () => {
const image = imageRef.current;
const positioning = positioningRef.current;
const controlWrapper = controlWrapperRef.current;
if (image !== null && controlWrapper !== null && positioning.isResizing) {
const width = positioning.currentWidth;
const height = positioning.currentHeight;
positioning.startWidth = 0;
positioning.startHeight = 0;
positioning.ratio = 0;
positioning.startX = 0;
positioning.startY = 0;
positioning.currentWidth = 0;
positioning.currentHeight = 0;
positioning.isResizing = false;
controlWrapper.classList.remove('image-control-wrapper--resizing');
setEndCursor();
onResizeEnd(width, height);
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
}
};
return (
<div ref={controlWrapperRef}>
<div
className="image-resizer image-resizer-n"
onPointerDown={(event) => {
handlePointerDown(event, Direction.north);
}}
/>
<div
className="image-resizer image-resizer-ne"
onPointerDown={(event) => {
handlePointerDown(event, Direction.north | Direction.east);
}}
/>
<div
className="image-resizer image-resizer-e"
onPointerDown={(event) => {
handlePointerDown(event, Direction.east);
}}
/>
<div
className="image-resizer image-resizer-se"
onPointerDown={(event) => {
handlePointerDown(event, Direction.south | Direction.east);
}}
/>
<div
className="image-resizer image-resizer-s"
onPointerDown={(event) => {
handlePointerDown(event, Direction.south);
}}
/>
<div
className="image-resizer image-resizer-sw"
onPointerDown={(event) => {
handlePointerDown(event, Direction.south | Direction.west);
}}
/>
<div
className="image-resizer image-resizer-w"
onPointerDown={(event) => {
handlePointerDown(event, Direction.west);
}}
/>
<div
className="image-resizer image-resizer-nw"
onPointerDown={(event) => {
handlePointerDown(event, Direction.north | Direction.west);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import {
$applyNodeReplacement,
type DOMConversionMap,
type DOMConversionOutput,
type DOMExportOutput,
type EditorConfig,
type LexicalNode,
type NodeKey,
type SerializedTextNode,
type Spread,
TextNode,
} from 'lexical';
export type SerializedMentionNode = Spread<
{
mentionName: string;
},
SerializedTextNode
>;
function $convertMentionElement(
domNode: HTMLElement,
): DOMConversionOutput | null {
const textContent = domNode.textContent;
const mentionName = domNode.getAttribute('data-lexical-mention-name');
if (textContent !== null) {
const node = $createMentionNode(
typeof mentionName === 'string' ? mentionName : textContent,
textContent,
);
return {
node,
};
}
return null;
}
const mentionStyle = 'background-color: rgba(24, 119, 232, 0.2)';
export class MentionNode extends TextNode {
__mention: string;
static getType(): string {
return 'mention';
}
static clone(node: MentionNode): MentionNode {
return new MentionNode(node.__mention, node.__text, node.__key);
}
static importJSON(serializedNode: SerializedMentionNode): MentionNode {
return $createMentionNode(serializedNode.mentionName).updateFromJSON(
serializedNode,
);
}
constructor(mentionName: string, text?: string, key?: NodeKey) {
super(text ?? mentionName, key);
this.__mention = mentionName;
}
exportJSON(): SerializedMentionNode {
return {
...super.exportJSON(),
mentionName: this.__mention,
};
}
createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
dom.style.cssText = mentionStyle;
dom.className = 'mention';
dom.spellcheck = false;
return dom;
}
exportDOM(): DOMExportOutput {
const element = document.createElement('span');
element.setAttribute('data-lexical-mention', 'true');
if (this.__text !== this.__mention) {
element.setAttribute('data-lexical-mention-name', this.__mention);
}
element.textContent = this.__text;
return {element};
}
static importDOM(): DOMConversionMap | null {
return {
span: (domNode: HTMLElement) => {
if (!domNode.hasAttribute('data-lexical-mention')) {
return null;
}
return {
conversion: $convertMentionElement,
priority: 1,
};
},
};
}
isTextEntity(): true {
return true;
}
canInsertTextBefore(): boolean {
return false;
}
canInsertTextAfter(): boolean {
return false;
}
}
export function $createMentionNode(
mentionName: string,
textContent?: string,
): MentionNode {
const mentionNode = new MentionNode(mentionName, textContent ?? mentionName);
mentionNode.setMode('segmented').toggleDirectionless();
return $applyNodeReplacement(mentionNode);
}
export function $isMentionNode(
node: LexicalNode | null | undefined,
): node is MentionNode {
return node instanceof MentionNode;
}

View File

@@ -0,0 +1,101 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { DRAG_DROP_PASTE } from '@lexical/rich-text';
import { isMimeType, mediaFileReader } from '@lexical/utils';
import { COMMAND_PRIORITY_LOW } from 'lexical';
import { useEffect } from 'react';
import { INSERT_IMAGE_COMMAND } from './ImagesPlugin';
import { uploadImage } from '../api';
const ACCEPTABLE_IMAGE_TYPES = [
'image/',
'image/heic',
'image/heif',
'image/gif',
'image/webp',
'image/png',
'image/jpeg',
'image/jpg',
'image/svg+xml',
];
export default function DragDropPastePlugin(): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
DRAG_DROP_PASTE,
(files) => {
(async () => {
const filesResult = await mediaFileReader(
files,
[ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x),
);
for (const { file, result } of filesResult) {
if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
// Insert image with preview first
const imagePayload = {
altText: file.name,
src: result, // data URL for preview
uploadProgress: 0,
};
editor.dispatchCommand(INSERT_IMAGE_COMMAND, imagePayload);
// Upload to S3 in background
try {
const { fileKey, url } = await uploadImage(file, (progress) => {
// Update progress
editor.update(() => {
const nodes = editor._editorState._nodeMap;
nodes.forEach((node: any) => {
if (node.__type === 'image' && node.__src === result) {
node.setUploadProgress(progress);
}
});
});
});
// Update with final URL and fileKey
editor.update(() => {
const nodes = editor._editorState._nodeMap;
nodes.forEach((node: any) => {
if (node.__type === 'image' && node.__src === result) {
node.setSrc(url);
node.setFileKey(fileKey);
node.setUploadProgress(100);
node.setUploadError(undefined);
}
});
});
// Show success notification
console.log('✅ Image uploaded successfully');
} catch (error) {
// Update with error
editor.update(() => {
const nodes = editor._editorState._nodeMap;
nodes.forEach((node: any) => {
if (node.__type === 'image' && node.__src === result) {
node.setUploadError(
error instanceof Error ? error.message : 'Upload failed'
);
}
});
});
// Show error notification
console.error('❌ Image upload failed:', error);
}
}
}
})();
return true;
},
COMMAND_PRIORITY_LOW,
);
}, [editor]);
return null;
}

View File

@@ -0,0 +1,18 @@
import type {JSX} from 'react';
import {HashtagNode, registerLexicalHashtag} from '@lexical/hashtag';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useEffect} from 'react';
export default function HashtagPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([HashtagNode])) {
throw new Error('HashtagPlugin: HashtagNode not registered on editor');
}
return registerLexicalHashtag(editor);
}, [editor]);
return null;
}

View File

@@ -0,0 +1,47 @@
import type { LexicalCommand } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils';
import {
$createParagraphNode,
$insertNodes,
$isRootOrShadowRoot,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical';
import { useEffect } from 'react';
import { $createImageNode, ImageNode, type ImagePayload } from '../nodes/ImageNode';
export type InsertImagePayload = Readonly<ImagePayload>;
export const INSERT_IMAGE_COMMAND: LexicalCommand<InsertImagePayload> =
createCommand('INSERT_IMAGE_COMMAND');
export default function ImagesPlugin(): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([ImageNode])) {
throw new Error('ImagesPlugin: ImageNode not registered on editor');
}
return mergeRegister(
editor.registerCommand<InsertImagePayload>(
INSERT_IMAGE_COMMAND,
(payload) => {
const imageNode = $createImageNode(payload);
$insertNodes([imageNode]);
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd();
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
);
}, [editor]);
return null;
}

View File

@@ -0,0 +1,7 @@
import type { JSX } from 'react';
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin';
import { EDITOR_TRANSFORMERS } from './MarkdownTransformers';
export default function MarkdownPlugin(): JSX.Element {
return <MarkdownShortcutPlugin transformers={EDITOR_TRANSFORMERS} />;
}

View File

@@ -0,0 +1,258 @@
import type { ElementTransformer, TextMatchTransformer, Transformer } from '@lexical/markdown';
import {
CHECK_LIST,
ELEMENT_TRANSFORMERS,
MULTILINE_ELEMENT_TRANSFORMERS,
TEXT_FORMAT_TRANSFORMERS,
TEXT_MATCH_TRANSFORMERS,
} from '@lexical/markdown';
import {
$createHorizontalRuleNode,
$isHorizontalRuleNode,
} from '@lexical/react/LexicalHorizontalRuleNode';
import {
$createTableCellNode,
$createTableNode,
$createTableRowNode,
$isTableCellNode,
$isTableNode,
$isTableRowNode,
TableCellHeaderStates,
type TableCellNode,
type TableNode,
} from '@lexical/table';
import {
$createTextNode,
$isParagraphNode,
$isTextNode,
type LexicalNode,
} from 'lexical';
import { $createImageNode, $isImageNode } from '../nodes/ImageNode';
// Horizontal Rule transformer (---, ***, ___)
export const HR: ElementTransformer = {
dependencies: [],
export: (node: LexicalNode) => {
return $isHorizontalRuleNode(node) ? '***' : null;
},
regExp: /^(---|\*\*\*|___)\s?$/,
replace: (parentNode, _1, _2, isImport) => {
const line = $createHorizontalRuleNode();
if (isImport || parentNode.getNextSibling() != null) {
parentNode.replace(line);
} else {
parentNode.insertBefore(line);
}
line.selectNext();
},
type: 'element',
};
// Image transformer ![alt](url)
export const IMAGE: TextMatchTransformer = {
dependencies: [],
export: (node) => {
if (!$isImageNode(node)) {
return null;
}
return `![${node.getAltText()}](${node.getSrc()})`;
},
importRegExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))/,
regExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))$/,
replace: (textNode, match) => {
const [, altText, src] = match;
const imageNode = $createImageNode({
altText,
maxWidth: 800,
src,
});
textNode.replace(imageNode);
},
trigger: ')',
type: 'text-match',
};
// Table transformer
const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/;
const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-*:? ?)+\|\s?$/;
const $createTableCell = (textContent: string): TableCellNode => {
const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS);
const text = $createTextNode(textContent.trim());
cell.append(text);
return cell;
};
const mapToTableCells = (textContent: string): Array<TableCellNode> | null => {
const match = textContent.match(TABLE_ROW_REG_EXP);
if (!match || !match[1]) {
return null;
}
return match[1].split('|').map((text) => $createTableCell(text));
};
function getTableColumnsSize(table: TableNode) {
const row = table.getFirstChild();
return $isTableRowNode(row) ? row.getChildrenSize() : 0;
}
export const TABLE: ElementTransformer = {
dependencies: [],
export: (node: LexicalNode) => {
if (!$isTableNode(node)) {
return null;
}
const output: string[] = [];
for (const row of node.getChildren()) {
const rowOutput = [];
if (!$isTableRowNode(row)) {
continue;
}
let isHeaderRow = false;
for (const cell of row.getChildren()) {
if ($isTableCellNode(cell)) {
const textContent = cell.getTextContent().replace(/\n/g, '\\n').trim();
rowOutput.push(textContent);
if (cell.__headerState === TableCellHeaderStates.ROW) {
isHeaderRow = true;
}
}
}
output.push(`| ${rowOutput.join(' | ')} |`);
if (isHeaderRow) {
output.push(`| ${rowOutput.map((_) => '---').join(' | ')} |`);
}
}
return output.join('\n');
},
regExp: TABLE_ROW_REG_EXP,
replace: (parentNode, _1, match) => {
// Header row divider
if (TABLE_ROW_DIVIDER_REG_EXP.test(match[0])) {
const table = parentNode.getPreviousSibling();
if (!table || !$isTableNode(table)) {
return;
}
const rows = table.getChildren();
const lastRow = rows[rows.length - 1];
if (!lastRow || !$isTableRowNode(lastRow)) {
return;
}
// Add header state to row cells
lastRow.getChildren().forEach((cell) => {
if (!$isTableCellNode(cell)) {
return;
}
cell.setHeaderStyles(
TableCellHeaderStates.ROW,
TableCellHeaderStates.ROW,
);
});
// Remove divider line
parentNode.remove();
return;
}
const matchCells = mapToTableCells(match[0]);
if (matchCells == null) {
return;
}
const rows = [matchCells];
let sibling = parentNode.getPreviousSibling();
let maxCells = matchCells.length;
while (sibling) {
if (!$isParagraphNode(sibling)) {
break;
}
if (sibling.getChildrenSize() !== 1) {
break;
}
const firstChild = sibling.getFirstChild();
if (!$isTextNode(firstChild)) {
break;
}
const cells = mapToTableCells(firstChild.getTextContent());
if (cells == null) {
break;
}
maxCells = Math.max(maxCells, cells.length);
rows.unshift(cells);
const previousSibling = sibling.getPreviousSibling();
sibling.remove();
sibling = previousSibling;
}
const table = $createTableNode();
for (const cells of rows) {
const tableRow = $createTableRowNode();
table.append(tableRow);
for (let i = 0; i < maxCells; i++) {
tableRow.append(i < cells.length ? cells[i] : $createTableCell(''));
}
}
const previousSibling = parentNode.getPreviousSibling();
if (
$isTableNode(previousSibling) &&
getTableColumnsSize(previousSibling) === maxCells
) {
previousSibling.append(...table.getChildren());
parentNode.remove();
} else {
parentNode.replace(table);
}
table.selectEnd();
},
type: 'element',
};
// Export all transformers for full markdown support
// Includes support for:
// - Headings (# ## ###)
// - Bold (**text** or __text__)
// - Italic (*text* or _text_)
// - Strikethrough (~~text~~)
// - Code (`code`)
// - Links ([text](url))
// - Lists (ordered and unordered)
// - Checkboxes (- [ ] or - [x])
// - Blockquotes (>)
// - Code blocks (```)
// - Horizontal rules (---, ***, ___)
// - Tables (| col1 | col2 |)
// - Images (![alt](url))
export const EDITOR_TRANSFORMERS: Array<Transformer> = [
TABLE,
HR,
IMAGE,
CHECK_LIST,
...ELEMENT_TRANSFORMERS,
...MULTILINE_ELEMENT_TRANSFORMERS,
...TEXT_FORMAT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS,
];

View File

@@ -0,0 +1,288 @@
import type {JSX} from 'react';
import type {MenuTextMatch} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
LexicalTypeaheadMenuPlugin,
MenuOption,
useBasicTypeaheadTriggerMatch,
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import {TextNode} from 'lexical';
import {useCallback, useEffect, useMemo, useState} from 'react';
import * as ReactDOM from 'react-dom';
import {$createMentionNode} from '../nodes/MentionNode';
const PUNCTUATION =
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';
const TRIGGERS = ['@'].join('');
// Chars we expect to see in a mention (non-space, non-punctuation).
const VALID_CHARS = '[^' + TRIGGERS + PUNCTUATION + '\\s]';
// Non-standard series of chars. Each series must be preceded and followed by
// a valid char.
const VALID_JOINS =
'(?:' +
'\\.[ |$]|' + // E.g. "r. " in "Mr. Smith"
' |' + // E.g. " " in "Josh Duck"
'[' +
PUNCTUATION +
']|' + // E.g. "-' in "Salier-Hellendag"
')';
const LENGTH_LIMIT = 75;
const AtSignMentionsRegex = new RegExp(
'(^|\\s|\\()(' +
'[' +
TRIGGERS +
']' +
'((?:' +
VALID_CHARS +
VALID_JOINS +
'){0,' +
LENGTH_LIMIT +
'})' +
')$',
);
// 50 is the longest alias length limit.
const ALIAS_LENGTH_LIMIT = 50;
// Regex used to match alias.
const AtSignMentionsRegexAliasRegex = new RegExp(
'(^|\\s|\\()(' +
'[' +
TRIGGERS +
']' +
'((?:' +
VALID_CHARS +
'){0,' +
ALIAS_LENGTH_LIMIT +
'})' +
')$',
);
// At most, 5 suggestions are shown in the popup.
const SUGGESTION_LIST_LENGTH_LIMIT = 5;
const mentionsCache = new Map();
const dummyMentionsData = [
'Alice Johnson',
'Bob Smith',
'Charlie Brown',
'David Wilson',
'Emma Davis',
'Frank Miller',
'Grace Lee',
'Henry Taylor',
'Isabella Martinez',
'Jack Anderson',
];
const dummyLookupService = {
search(string: string, callback: (results: Array<string>) => void): void {
setTimeout(() => {
const results = dummyMentionsData.filter((mention) =>
mention.toLowerCase().includes(string.toLowerCase()),
);
callback(results);
}, 100);
},
};
function useMentionLookupService(mentionString: string | null) {
const [results, setResults] = useState<Array<string>>([]);
useEffect(() => {
const cachedResults = mentionsCache.get(mentionString);
if (mentionString == null) {
setResults([]);
return;
}
if (cachedResults === null) {
return;
} else if (cachedResults !== undefined) {
setResults(cachedResults);
return;
}
mentionsCache.set(mentionString, null);
dummyLookupService.search(mentionString, (newResults) => {
mentionsCache.set(mentionString, newResults);
setResults(newResults);
});
}, [mentionString]);
return results;
}
function checkForAtSignMentions(
text: string,
minMatchLength: number,
): MenuTextMatch | null {
let match = AtSignMentionsRegex.exec(text);
if (match === null) {
match = AtSignMentionsRegexAliasRegex.exec(text);
}
if (match !== null) {
// The strategy ignores leading whitespace but we need to know it's
// length to add it to the leadOffset
const maybeLeadingWhitespace = match[1];
const matchingString = match[3];
if (matchingString.length >= minMatchLength) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[2],
};
}
}
return null;
}
function getPossibleQueryMatch(text: string): MenuTextMatch | null {
return checkForAtSignMentions(text, 1);
}
class MentionTypeaheadOption extends MenuOption {
name: string;
picture: JSX.Element;
constructor(name: string, picture: JSX.Element) {
super(name);
this.name = name;
this.picture = picture;
}
}
function MentionsTypeaheadMenuItem({
index,
isSelected,
onClick,
onMouseEnter,
option,
}: {
index: number;
isSelected: boolean;
onClick: () => void;
onMouseEnter: () => void;
option: MentionTypeaheadOption;
}) {
let className = 'item';
if (isSelected) {
className += ' selected';
}
return (
<li
key={option.key}
tabIndex={-1}
className={className}
ref={option.setRefElement}
role="option"
aria-selected={isSelected}
id={'typeahead-item-' + index}
onMouseEnter={onMouseEnter}
onClick={onClick}>
{option.picture}
<span className="text">{option.name}</span>
</li>
);
}
export default function MentionsPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext();
const [queryString, setQueryString] = useState<string | null>(null);
const results = useMentionLookupService(queryString);
const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
minLength: 0,
});
const options = useMemo(
() =>
results
.map(
(result) =>
new MentionTypeaheadOption(result, <i className="icon user" />),
)
.slice(0, SUGGESTION_LIST_LENGTH_LIMIT),
[results],
);
const onSelectOption = useCallback(
(
selectedOption: MentionTypeaheadOption,
nodeToReplace: TextNode | null,
closeMenu: () => void,
) => {
editor.update(() => {
const mentionNode = $createMentionNode(selectedOption.name);
if (nodeToReplace) {
nodeToReplace.replace(mentionNode);
}
mentionNode.select();
closeMenu();
});
},
[editor],
);
const checkForMentionMatch = useCallback(
(text: string) => {
const slashMatch = checkForSlashTriggerMatch(text, editor);
if (slashMatch !== null) {
return null;
}
return getPossibleQueryMatch(text);
},
[checkForSlashTriggerMatch, editor],
);
return (
<LexicalTypeaheadMenuPlugin<MentionTypeaheadOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForMentionMatch}
options={options}
menuRenderFn={(
anchorElementRef,
{selectedIndex, selectOptionAndCleanUp, setHighlightedIndex},
) =>
anchorElementRef.current && results.length
? ReactDOM.createPortal(
<div className="typeahead-popover mentions-menu">
<ul>
{options.map((option, i: number) => (
<MentionsTypeaheadMenuItem
index={i}
isSelected={selectedIndex === i}
onClick={() => {
setHighlightedIndex(i);
selectOptionAndCleanUp(option);
}}
onMouseEnter={() => {
setHighlightedIndex(i);
}}
key={option.key}
option={option}
/>
))}
</ul>
</div>,
anchorElementRef.current,
)
: null
}
/>
);
}

View File

@@ -0,0 +1,301 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
REDO_COMMAND,
UNDO_COMMAND,
SELECTION_CHANGE_COMMAND,
FORMAT_TEXT_COMMAND,
FORMAT_ELEMENT_COMMAND,
COMMAND_PRIORITY_CRITICAL,
$getSelection,
$isRangeSelection,
$isRootOrShadowRoot,
$isElementNode,
} from 'lexical';
import type { ElementFormatType, TextFormatType } from 'lexical';
import { $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection';
import { $findMatchingParent } from '@lexical/utils';
import DropdownColorPicker from '../ui/DropdownColorPicker';
import { exportToJSON, importFromJSON } from '../utils/exportImport';
import '../styles/toolbar.css';
export default function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const toolbarRef = useRef(null);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isCode, setIsCode] = useState(false);
const [fontColor, setFontColor] = useState('#000');
const [bgColor, setBgColor] = useState('#fff');
const [fontSize, setFontSize] = useState('15px');
const [elementFormat, setElementFormat] = useState<ElementFormatType>('left');
const updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// Update text format
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
setIsCode(selection.hasFormat('code'));
// Update color
setFontColor(
$getSelectionStyleValueForProperty(selection, 'color', '#000')
);
setBgColor(
$getSelectionStyleValueForProperty(selection, 'background-color', '#fff')
);
setFontSize(
$getSelectionStyleValueForProperty(selection, 'font-size', '15px')
);
// Update element format (alignment)
const node = selection.anchor.getNode();
const element =
node.getKey() === 'root'
? node
: $findMatchingParent(node, (e) => {
const parent = e.getParent();
return parent !== null && $isRootOrShadowRoot(parent);
});
if (element !== null && $isElementNode(element)) {
const formatType = element.getFormatType();
setElementFormat(formatType || 'left');
}
}
}, [editor]);
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateToolbar();
return false;
},
COMMAND_PRIORITY_CRITICAL
);
}, [editor, updateToolbar]);
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateToolbar();
});
});
}, [editor, updateToolbar]);
useEffect(() => {
return editor.registerCommand(
CAN_UNDO_COMMAND,
(payload) => {
setCanUndo(payload);
return false;
},
COMMAND_PRIORITY_CRITICAL
);
}, [editor]);
useEffect(() => {
return editor.registerCommand(
CAN_REDO_COMMAND,
(payload) => {
setCanRedo(payload);
return false;
},
COMMAND_PRIORITY_CRITICAL
);
}, [editor]);
const formatText = (format: TextFormatType) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
};
const onFontColorSelect = useCallback(
(value: string) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$patchStyleText(selection, { color: value });
}
});
},
[editor]
);
const onBgColorSelect = useCallback(
(value: string) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$patchStyleText(selection, { 'background-color': value });
}
});
},
[editor]
);
const onFontSizeChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$patchStyleText(selection, { 'font-size': value });
}
});
},
[editor]
);
return (
<div className="toolbar" ref={toolbarRef}>
<button
disabled={!canUndo}
onClick={() => {
editor.dispatchCommand(UNDO_COMMAND, undefined);
}}
className="toolbar-item spaced"
aria-label="Undo">
<i className="format undo" />
</button>
<button
disabled={!canRedo}
onClick={() => {
editor.dispatchCommand(REDO_COMMAND, undefined);
}}
className="toolbar-item"
aria-label="Redo">
<i className="format redo" />
</button>
<div className="divider" />
<select
className="toolbar-item block-controls"
value={fontSize}
onChange={onFontSizeChange}>
<option value="10px">10px</option>
<option value="11px">11px</option>
<option value="12px">12px</option>
<option value="13px">13px</option>
<option value="14px">14px</option>
<option value="15px">15px</option>
<option value="16px">16px</option>
<option value="18px">18px</option>
<option value="20px">20px</option>
<option value="24px">24px</option>
<option value="30px">30px</option>
<option value="36px">36px</option>
</select>
<div className="divider" />
<button
onClick={() => formatText('bold')}
className={'toolbar-item spaced ' + (isBold ? 'active' : '')}
aria-label="Format Bold">
<i className="format bold" />
</button>
<button
onClick={() => formatText('italic')}
className={'toolbar-item spaced ' + (isItalic ? 'active' : '')}
aria-label="Format Italics">
<i className="format italic" />
</button>
<button
onClick={() => formatText('underline')}
className={'toolbar-item spaced ' + (isUnderline ? 'active' : '')}
aria-label="Format Underline">
<i className="format underline" />
</button>
<button
onClick={() => formatText('strikethrough')}
className={'toolbar-item spaced ' + (isStrikethrough ? 'active' : '')}
aria-label="Format Strikethrough">
<i className="format strikethrough" />
</button>
<button
onClick={() => formatText('code')}
className={'toolbar-item spaced ' + (isCode ? 'active' : '')}
aria-label="Insert Code">
<i className="format code" />
</button>
<div className="divider" />
<DropdownColorPicker
buttonClassName="toolbar-item color-picker"
buttonAriaLabel="Formatting text color"
buttonIconClassName="icon font-color"
color={fontColor}
onChange={onFontColorSelect}
title="text color"
/>
<DropdownColorPicker
buttonClassName="toolbar-item color-picker"
buttonAriaLabel="Formatting background color"
buttonIconClassName="icon bg-color"
color={bgColor}
onChange={onBgColorSelect}
title="bg color"
/>
<div className="divider" />
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left');
}}
className={'toolbar-item spaced ' + (elementFormat === 'left' ? 'active' : '')}
aria-label="Left Align">
<i className="format left-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center');
}}
className={'toolbar-item spaced ' + (elementFormat === 'center' ? 'active' : '')}
aria-label="Center Align">
<i className="format center-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right');
}}
className={'toolbar-item spaced ' + (elementFormat === 'right' ? 'active' : '')}
aria-label="Right Align">
<i className="format right-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify');
}}
className={'toolbar-item spaced ' + (elementFormat === 'justify' ? 'active' : '')}
aria-label="Justify Align">
<i className="format justify-align" />
</button>
<div className="divider" />
<button
onClick={() => importFromJSON(editor)}
className="toolbar-item"
title="Import from JSON"
aria-label="Import editor state from JSON file">
<i className="format import" />
</button>
<button
onClick={() => exportToJSON(editor)}
className="toolbar-item"
title="Export to JSON"
aria-label="Export editor state to JSON file">
<i className="format export" />
</button>
</div>
);
}

477
src/blog/styles/editor.css Normal file
View File

@@ -0,0 +1,477 @@
.editor-container {
margin: 20px auto;
border-radius: 8px;
max-width: 1100px;
color: #000;
position: relative;
line-height: 1.7;
font-weight: 400;
text-align: left;
border: 1px solid #e0e0e0;
background: #fff;
}
.editor-inner {
background: #fff;
position: relative;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.editor-input {
min-height: 300px;
resize: vertical;
font-size: 15px;
caret-color: rgb(5, 5, 5);
position: relative;
tab-size: 1;
outline: 0;
padding: 15px 20px;
caret-color: #444;
}
.editor-placeholder {
color: #999;
overflow: hidden;
position: absolute;
text-overflow: ellipsis;
top: 15px;
left: 20px;
font-size: 15px;
user-select: none;
display: inline-block;
pointer-events: none;
}
.editor-paragraph {
margin: 0;
margin-bottom: 8px;
position: relative;
}
.editor-paragraph:last-child {
margin-bottom: 0;
}
.editor-heading-h1 {
font-size: 2em;
font-weight: 700;
margin: 0;
margin-bottom: 12px;
padding: 0;
}
.editor-heading-h2 {
font-size: 1.5em;
font-weight: 700;
margin: 0;
margin-bottom: 10px;
padding: 0;
}
.editor-heading-h3 {
font-size: 1.25em;
font-weight: 700;
margin: 0;
margin-bottom: 8px;
padding: 0;
}
.editor-quote {
margin: 0;
margin-left: 20px;
margin-bottom: 10px;
font-size: 15px;
color: #666;
border-left: 4px solid #ccc;
padding-left: 16px;
}
.editor-code {
background-color: #f4f4f4;
font-family: Menlo, Consolas, Monaco, monospace;
display: block;
padding: 8px 12px;
line-height: 1.53;
font-size: 13px;
margin: 0;
margin-top: 8px;
margin-bottom: 8px;
overflow-x: auto;
position: relative;
tab-size: 2;
border-radius: 4px;
}
.editor-text-bold {
font-weight: bold;
}
.editor-text-italic {
font-style: italic;
}
.editor-text-underline {
text-decoration: underline;
}
.editor-text-strikethrough {
text-decoration: line-through;
}
.editor-text-code {
background-color: #f4f4f4;
padding: 1px 4px;
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 90%;
border-radius: 3px;
}
.editor-list-ol {
padding: 0;
margin: 0;
margin-left: 20px;
}
.editor-list-ul {
padding: 0;
margin: 0;
margin-left: 20px;
}
.editor-listitem {
margin: 4px 0;
}
.editor-nested-listitem {
list-style-type: none;
}
/* Horizontal Rule */
.editor-hr {
border: none;
border-top: 2px solid #ccc;
margin: 15px 0;
}
/* Link */
.editor-link {
color: #0066cc;
text-decoration: none;
cursor: pointer;
}
.editor-link:hover {
text-decoration: underline;
}
/* Table */
.editor-table {
border-collapse: collapse;
border-spacing: 0;
overflow-y: scroll;
overflow-x: scroll;
table-layout: fixed;
width: max-content;
margin: 15px 0;
}
.editor-table-cell {
border: 1px solid #ccc;
min-width: 75px;
vertical-align: top;
text-align: start;
padding: 6px 8px;
position: relative;
outline: none;
}
.editor-table-cell-header {
background-color: #f4f4f4;
font-weight: bold;
text-align: left;
}
/* Checklist */
.editor-listitem-checked,
.editor-listitem-unchecked {
position: relative;
margin-left: 8px;
margin-right: 8px;
padding-left: 24px;
padding-right: 24px;
list-style-type: none;
outline: none;
}
.editor-listitem-checked {
text-decoration: line-through;
}
.editor-listitem-unchecked:before,
.editor-listitem-checked:before {
content: '';
width: 16px;
height: 16px;
top: 2px;
left: 0;
cursor: pointer;
display: block;
background-size: cover;
position: absolute;
}
.editor-listitem-unchecked:before {
border: 1px solid #999;
border-radius: 2px;
}
.editor-listitem-checked:before {
border: 1px solid #0066cc;
border-radius: 2px;
background-color: #0066cc;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='white' d='M13.5 2l-7.5 7.5-3.5-3.5-1.5 1.5 5 5 9-9z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
}
.editor-listitem-checked[dir='rtl']:before,
.editor-listitem-unchecked[dir='rtl']:before {
left: auto;
right: 0;
}
.editor-listitem-checked[dir='rtl'],
.editor-listitem-unchecked[dir='rtl'] {
padding-left: 24px;
padding-right: 24px;
}
/* Image */
.editor-image {
cursor: default;
display: inline-block;
position: relative;
user-select: none;
}
.editor-image img {
max-width: 100%;
cursor: default;
}
.editor-image img.focused {
outline: 2px solid rgb(60, 132, 244);
user-select: none;
}
.editor-image img.focused.draggable {
cursor: grab;
}
.editor-image img.focused.draggable:active {
cursor: grabbing;
}
.image-control-wrapper--resizing {
touch-action: none;
}
/* Image Resizer */
.image-resizer {
display: block;
width: 7px;
height: 7px;
position: absolute;
background-color: rgb(60, 132, 244);
border: 1px solid #fff;
}
.image-resizer.image-resizer-n {
top: -6px;
left: 48%;
cursor: n-resize;
}
.image-resizer.image-resizer-ne {
top: -6px;
right: -6px;
cursor: ne-resize;
}
.image-resizer.image-resizer-e {
bottom: 48%;
right: -6px;
cursor: e-resize;
}
.image-resizer.image-resizer-se {
bottom: -2px;
right: -6px;
cursor: nwse-resize;
}
.image-resizer.image-resizer-s {
bottom: -2px;
left: 48%;
cursor: s-resize;
}
.image-resizer.image-resizer-sw {
bottom: -2px;
left: -6px;
cursor: sw-resize;
}
.image-resizer.image-resizer-w {
bottom: 48%;
left: -6px;
cursor: w-resize;
}
.image-resizer.image-resizer-nw {
top: -6px;
left: -6px;
cursor: nw-resize;
}
/* Text Alignment */
.editor-text-left {
text-align: left;
}
.editor-text-center {
text-align: center;
}
.editor-text-right {
text-align: right;
}
.editor-text-justify {
text-align: justify;
}
/* Hashtag styles */
.editor-hashtag {
background-color: rgba(88, 144, 255, 0.15);
border-bottom: 1px solid rgba(88, 144, 255, 0.3);
font-weight: 500;
}
/* Mention styles */
.mention {
background-color: rgba(24, 119, 232, 0.2);
color: #1877e8;
border-radius: 4px;
padding: 1px 3px;
font-weight: 500;
cursor: pointer;
}
.mention:focus {
box-shadow: rgb(180 213 255) 0px 0px 0px 2px;
outline: none;
}
/* Typeahead popover styles */
.typeahead-popover {
background: #fff;
box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
border-radius: 8px;
position: relative;
z-index: 5;
}
.typeahead-popover ul {
padding: 0;
list-style: none;
margin: 0;
border-radius: 8px;
max-height: 200px;
overflow-y: scroll;
}
.typeahead-popover ul::-webkit-scrollbar {
display: none;
}
.typeahead-popover ul {
-ms-overflow-style: none;
scrollbar-width: none;
}
.typeahead-popover ul li {
margin: 0;
min-width: 180px;
font-size: 14px;
outline: none;
cursor: pointer;
border-radius: 8px;
}
.typeahead-popover ul li.selected {
background: #eee;
}
.typeahead-popover li {
margin: 0 8px 0 8px;
padding: 8px;
color: #050505;
cursor: pointer;
line-height: 16px;
font-size: 15px;
display: flex;
align-content: center;
flex-direction: row;
flex-shrink: 0;
background-color: #fff;
border-radius: 8px;
border: 0;
}
.typeahead-popover li.active {
display: flex;
width: 20px;
height: 20px;
background-size: contain;
}
.typeahead-popover li:first-child {
border-radius: 8px 8px 0px 0px;
}
.typeahead-popover li:last-child {
border-radius: 0px 0px 8px 8px;
}
.typeahead-popover li:hover {
background-color: #eee;
}
.typeahead-popover li .text {
display: flex;
line-height: 20px;
flex-grow: 1;
min-width: 150px;
}
.typeahead-popover li .icon {
display: flex;
width: 20px;
height: 20px;
user-select: none;
margin-right: 8px;
line-height: 16px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.mentions-menu {
width: 250px;
}
.typeahead-popover li .icon.user {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666'%3E%3Cpath d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/%3E%3C/svg%3E");
}

155
src/blog/styles/toolbar.css Normal file
View File

@@ -0,0 +1,155 @@
.toolbar {
display: flex;
background: #fff;
padding: 8px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
vertical-align: middle;
border-bottom: 1px solid #e0e0e0;
gap: 4px;
flex-wrap: wrap;
align-items: center;
}
.toolbar button {
border: 0;
display: flex;
background: none;
border-radius: 4px;
padding: 6px;
cursor: pointer;
vertical-align: middle;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.toolbar button:hover:not([disabled]) {
background-color: #f0f0f0;
}
.toolbar button:disabled {
cursor: not-allowed;
opacity: 0.3;
}
.toolbar button.active {
background-color: rgba(24, 144, 255, 0.1);
}
.toolbar-item {
border: 0;
display: flex;
background: none;
border-radius: 4px;
padding: 6px;
cursor: pointer;
vertical-align: middle;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
}
.toolbar-item.spaced {
margin-right: 2px;
}
.toolbar-item.block-controls {
background: none;
border: 1px solid #d0d0d0;
border-radius: 4px;
padding: 4px 8px;
font-size: 14px;
cursor: pointer;
min-width: 80px;
}
.toolbar-item i.format {
background-size: contain;
display: inline-block;
height: 18px;
width: 18px;
vertical-align: -0.25em;
opacity: 0.7;
}
.toolbar-item.active i.format,
.toolbar-item:hover:not([disabled]) i.format {
opacity: 1;
}
.divider {
width: 1px;
background-color: #e0e0e0;
margin: 0 4px;
height: 24px;
}
i.format.undo {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"/></svg>');
}
i.format.redo {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 019-9 9 9 0 016 2.3l3 2.7"/></svg>');
}
i.format.bold {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>');
}
i.format.italic {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>');
}
i.format.underline {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>');
}
i.format.strikethrough {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.3 5.3c-.94-.94-2.2-1.3-3.3-1.3H10a4 4 0 0 0 0 8"/><path d="M14 12a4 4 0 0 1 0 8h-4"/><line x1="4" y1="12" x2="20" y2="12"/></svg>');
}
i.format.code {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>');
}
i.icon.font-color {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20h16"/><path d="m6 16 6-12 6 12"/><path d="M8 12h8"/></svg>');
display: inline-block;
height: 18px;
width: 18px;
background-size: contain;
}
i.icon.bg-color {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 14c0-2.5-2-4-4-4H8c-2 0-4 1.5-4 4v6h16v-6Z"/><path d="M4 14V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8"/></svg>');
display: inline-block;
height: 18px;
width: 18px;
background-size: contain;
}
i.format.left-align {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="15" y2="12"/><line x1="3" y1="18" x2="18" y2="18"/></svg>');
}
i.format.center-align {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="6" y1="12" x2="18" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>');
}
i.format.right-align {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="9" y1="12" x2="21" y2="12"/><line x1="6" y1="18" x2="21" y2="18"/></svg>');
}
i.format.justify-align {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>');
}
i.format.import {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>');
}
i.format.export {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>');
}

View File

@@ -0,0 +1,46 @@
import type { EditorThemeClasses } from 'lexical';
const theme: EditorThemeClasses = {
paragraph: 'editor-paragraph',
quote: 'editor-quote',
heading: {
h1: 'editor-heading-h1',
h2: 'editor-heading-h2',
h3: 'editor-heading-h3',
h4: 'editor-heading-h4',
h5: 'editor-heading-h5',
h6: 'editor-heading-h6',
},
list: {
nested: {
listitem: 'editor-nested-listitem',
},
ol: 'editor-list-ol',
ul: 'editor-list-ul',
listitem: 'editor-listitem',
listitemChecked: 'editor-listitem-checked',
listitemUnchecked: 'editor-listitem-unchecked',
},
text: {
bold: 'editor-text-bold',
italic: 'editor-text-italic',
underline: 'editor-text-underline',
strikethrough: 'editor-text-strikethrough',
code: 'editor-text-code',
left: 'editor-text-left',
center: 'editor-text-center',
right: 'editor-text-right',
justify: 'editor-text-justify',
},
code: 'editor-code',
codeHighlight: {},
link: 'editor-link',
table: 'editor-table',
tableCell: 'editor-table-cell',
tableCellHeader: 'editor-table-cell-header',
hr: 'editor-hr',
image: 'editor-image',
hashtag: 'editor-hashtag',
};
export default theme;

33
src/blog/types.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Blog-related type definitions
*/
export interface UploadPresignedURLResponse {
url: string;
fileKey: string;
expireAt: number;
}
export interface DownloadPresignedURLResponse {
url: string;
expireAt: number;
}
export interface CreatePostResponse {
postId: string;
}
export interface ImageUploadPayload {
file: File;
onProgress?: (progress: number) => void;
onSuccess?: (fileKey: string, url: string) => void;
onError?: (error: Error) => void;
}
export interface ImageUploadStatus {
isUploading: boolean;
progress: number;
error?: string;
fileKey?: string;
url?: string;
}

View File

@@ -0,0 +1,50 @@
.color-picker-wrapper {
position: relative;
}
.color-preview {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 2px;
border: 1px solid #ccc;
margin-left: 4px;
vertical-align: middle;
}
.color-picker-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
.color-picker-basic-color {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
}
.color-picker-basic-color button {
width: 24px;
height: 24px;
border: 1px solid #ccc;
border-radius: 2px;
cursor: pointer;
padding: 0;
}
.color-picker-basic-color button:hover {
border-color: #333;
}
.color-picker-basic-color button.active {
border: 2px solid #1890ff;
border-radius: 4px;
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from 'react';
import './DropdownColorPicker.css';
const basicColors = [
'#000000', '#ffffff', '#888888', '#ff0000', '#00ff00', '#0000ff',
'#ffff00', '#00ffff', '#ff00ff', '#c0c0c0', '#808080', '#800000',
'#808000', '#008000', '#800080', '#008080', '#000080', '#ffa500',
'#a52a2a', '#dc143c', '#ff1493', '#ff69b4', '#ffd700', '#adff2f',
'#00fa9a', '#00ced1', '#1e90ff', '#9370db', '#ff6347', '#40e0d0',
];
type Props = {
buttonClassName: string;
buttonAriaLabel?: string;
buttonIconClassName?: string;
color: string;
onChange?: (color: string) => void;
title?: string;
};
export default function DropdownColorPicker({
buttonClassName,
buttonAriaLabel,
buttonIconClassName,
color,
onChange,
title,
}: Props) {
const [showPicker, setShowPicker] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setShowPicker(false);
}
};
if (showPicker) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [showPicker]);
return (
<div className="color-picker-wrapper">
<button
ref={buttonRef}
type="button"
className={buttonClassName}
onClick={() => setShowPicker(!showPicker)}
aria-label={buttonAriaLabel || title}
style={{ position: 'relative' }}>
{buttonIconClassName && <i className={buttonIconClassName} />}
<span
className="color-preview"
style={{ backgroundColor: color }}
/>
</button>
{showPicker && (
<div ref={dropdownRef} className="color-picker-dropdown">
<div className="color-picker-basic-color">
{basicColors.map((basicColor) => (
<button
key={basicColor}
className={basicColor === color ? ' active' : ''}
style={{ backgroundColor: basicColor }}
onClick={() => {
onChange?.(basicColor);
setShowPicker(false);
}}
/>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,292 @@
import type { LexicalEditor } from 'lexical';
import type { JSX } from 'react';
import { calculateZoomLevel } from '@lexical/utils';
import { useRef } from 'react';
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
const Direction = {
east: 1 << 0,
north: 1 << 3,
south: 1 << 1,
west: 1 << 2,
};
export default function ImageResizer({
onResizeStart,
onResizeEnd,
imageRef,
maxWidth,
editor,
}: {
editor: LexicalEditor;
imageRef: { current: null | HTMLElement };
maxWidth?: number;
onResizeEnd: (width: 'inherit' | number, height: 'inherit' | number) => void;
onResizeStart: () => void;
}): JSX.Element {
const controlWrapperRef = useRef<HTMLDivElement>(null);
const userSelect = useRef({
priority: '',
value: 'default',
});
const positioningRef = useRef<{
currentHeight: 'inherit' | number;
currentWidth: 'inherit' | number;
direction: number;
isResizing: boolean;
ratio: number;
startHeight: number;
startWidth: number;
startX: number;
startY: number;
}>({
currentHeight: 0,
currentWidth: 0,
direction: 0,
isResizing: false,
ratio: 0,
startHeight: 0,
startWidth: 0,
startX: 0,
startY: 0,
});
const editorRootElement = editor.getRootElement();
const maxWidthContainer = maxWidth
? maxWidth
: editorRootElement !== null
? editorRootElement.getBoundingClientRect().width - 20
: 100;
const maxHeightContainer =
editorRootElement !== null
? editorRootElement.getBoundingClientRect().height - 20
: 100;
const minWidth = 100;
const minHeight = 100;
const setStartCursor = (direction: number) => {
const ew = direction === Direction.east || direction === Direction.west;
const ns = direction === Direction.north || direction === Direction.south;
const nwse =
(direction & Direction.north && direction & Direction.west) ||
(direction & Direction.south && direction & Direction.east);
const cursorDir = ew ? 'ew' : ns ? 'ns' : nwse ? 'nwse' : 'nesw';
if (editorRootElement !== null) {
editorRootElement.style.setProperty(
'cursor',
`${cursorDir}-resize`,
'important',
);
}
if (document.body !== null) {
document.body.style.setProperty(
'cursor',
`${cursorDir}-resize`,
'important',
);
userSelect.current.value = document.body.style.getPropertyValue(
'-webkit-user-select',
);
userSelect.current.priority = document.body.style.getPropertyPriority(
'-webkit-user-select',
);
document.body.style.setProperty(
'-webkit-user-select',
`none`,
'important',
);
}
};
const setEndCursor = () => {
if (editorRootElement !== null) {
editorRootElement.style.setProperty('cursor', 'text');
}
if (document.body !== null) {
document.body.style.setProperty('cursor', 'default');
document.body.style.setProperty(
'-webkit-user-select',
userSelect.current.value,
userSelect.current.priority,
);
}
};
const handlePointerDown = (
event: React.PointerEvent<HTMLDivElement>,
direction: number,
) => {
if (!editor.isEditable()) {
return;
}
const image = imageRef.current;
const controlWrapper = controlWrapperRef.current;
if (image !== null && controlWrapper !== null) {
event.preventDefault();
const { width, height } = image.getBoundingClientRect();
const zoom = calculateZoomLevel(image);
const positioning = positioningRef.current;
positioning.startWidth = width;
positioning.startHeight = height;
positioning.ratio = width / height;
positioning.currentWidth = width;
positioning.currentHeight = height;
positioning.startX = event.clientX / zoom;
positioning.startY = event.clientY / zoom;
positioning.isResizing = true;
positioning.direction = direction;
setStartCursor(direction);
onResizeStart();
controlWrapper.classList.add('image-control-wrapper--resizing');
image.style.height = `${height}px`;
image.style.width = `${width}px`;
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
}
};
const handlePointerMove = (event: PointerEvent) => {
const image = imageRef.current;
const positioning = positioningRef.current;
const isHorizontal =
positioning.direction & (Direction.east | Direction.west);
const isVertical =
positioning.direction & (Direction.south | Direction.north);
if (image !== null && positioning.isResizing) {
const zoom = calculateZoomLevel(image);
if (isHorizontal && isVertical) {
let diff = Math.floor(positioning.startX - event.clientX / zoom);
diff = positioning.direction & Direction.east ? -diff : diff;
const width = clamp(
positioning.startWidth + diff,
minWidth,
maxWidthContainer,
);
const height = width / positioning.ratio;
image.style.width = `${width}px`;
image.style.height = `${height}px`;
positioning.currentHeight = height;
positioning.currentWidth = width;
} else if (isVertical) {
let diff = Math.floor(positioning.startY - event.clientY / zoom);
diff = positioning.direction & Direction.south ? -diff : diff;
const height = clamp(
positioning.startHeight + diff,
minHeight,
maxHeightContainer,
);
image.style.height = `${height}px`;
positioning.currentHeight = height;
} else {
let diff = Math.floor(positioning.startX - event.clientX / zoom);
diff = positioning.direction & Direction.east ? -diff : diff;
const width = clamp(
positioning.startWidth + diff,
minWidth,
maxWidthContainer,
);
image.style.width = `${width}px`;
positioning.currentWidth = width;
}
}
};
const handlePointerUp = () => {
const image = imageRef.current;
const positioning = positioningRef.current;
const controlWrapper = controlWrapperRef.current;
if (image !== null && controlWrapper !== null && positioning.isResizing) {
const width = positioning.currentWidth;
const height = positioning.currentHeight;
positioning.startWidth = 0;
positioning.startHeight = 0;
positioning.ratio = 0;
positioning.startX = 0;
positioning.startY = 0;
positioning.currentWidth = 0;
positioning.currentHeight = 0;
positioning.isResizing = false;
controlWrapper.classList.remove('image-control-wrapper--resizing');
setEndCursor();
onResizeEnd(width, height);
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
}
};
return (
<div ref={controlWrapperRef}>
<div
className="image-resizer image-resizer-n"
onPointerDown={(event) => {
handlePointerDown(event, Direction.north);
}}
/>
<div
className="image-resizer image-resizer-ne"
onPointerDown={(event) => {
handlePointerDown(event, Direction.north | Direction.east);
}}
/>
<div
className="image-resizer image-resizer-e"
onPointerDown={(event) => {
handlePointerDown(event, Direction.east);
}}
/>
<div
className="image-resizer image-resizer-se"
onPointerDown={(event) => {
handlePointerDown(event, Direction.south | Direction.east);
}}
/>
<div
className="image-resizer image-resizer-s"
onPointerDown={(event) => {
handlePointerDown(event, Direction.south);
}}
/>
<div
className="image-resizer image-resizer-sw"
onPointerDown={(event) => {
handlePointerDown(event, Direction.south | Direction.west);
}}
/>
<div
className="image-resizer image-resizer-w"
onPointerDown={(event) => {
handlePointerDown(event, Direction.west);
}}
/>
<div
className="image-resizer image-resizer-nw"
onPointerDown={(event) => {
handlePointerDown(event, Direction.north | Direction.west);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import type { LexicalEditor, SerializedEditorState } from 'lexical';
import { CLEAR_HISTORY_COMMAND } from 'lexical';
export interface SerializedDocument {
/** The serialized editorState produced by editorState.toJSON() */
editorState: SerializedEditorState;
/** The time this document was created in epoch milliseconds (Date.now()) */
lastSaved: number;
/** The source of the document, defaults to Cialloo */
source: string;
}
/**
* Generates a SerializedDocument from the current editor state
*/
function serializedDocumentFromEditorState(
editor: LexicalEditor,
config: Readonly<{
source?: string;
lastSaved?: number;
}> = Object.freeze({}),
): SerializedDocument {
return {
editorState: editor.getEditorState().toJSON(),
lastSaved: config.lastSaved || Date.now(),
source: config.source || 'Cialloo Editor',
};
}
/**
* Exports the editor content as a JSON file
*/
export function exportToJSON(
editor: LexicalEditor,
config: Readonly<{
fileName?: string;
source?: string;
}> = Object.freeze({}),
) {
const now = new Date();
const serializedDocument = serializedDocumentFromEditorState(editor, {
...config,
lastSaved: now.getTime(),
});
const fileName = config.fileName || `document-${now.toISOString()}`;
exportBlob(serializedDocument, `${fileName}.json`);
}
/**
* Creates a downloadable blob and triggers download
*/
function exportBlob(data: SerializedDocument, fileName: string) {
const a = document.createElement('a');
const body = document.body;
if (body === null) {
return;
}
body.appendChild(a);
a.style.display = 'none';
const json = JSON.stringify(data, null, 2); // Pretty print with 2 spaces
const blob = new Blob([json], {
type: 'application/json',
});
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = fileName;
a.click();
window.URL.revokeObjectURL(url);
a.remove();
}
/**
* Imports editor content from a JSON file
*/
export function importFromJSON(editor: LexicalEditor) {
readJSONFileFromSystem((text) => {
try {
const json = JSON.parse(text) as SerializedDocument;
const editorState = editor.parseEditorState(json.editorState);
editor.setEditorState(editorState);
editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
} catch (error) {
console.error('Failed to import JSON:', error);
alert('Failed to import file. Please make sure it\'s a valid JSON export.');
}
});
}
/**
* Reads a JSON file from the user's system
*/
function readJSONFileFromSystem(callback: (text: string) => void) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.addEventListener('change', (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
const file = target.files[0];
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = (readerEvent) => {
if (readerEvent.target) {
const content = readerEvent.target.result;
callback(content as string);
}
};
}
});
input.click();
}

View File

@@ -11,6 +11,7 @@ import ScrollToTop from './components/ScrollToTop'
import App from './App.tsx' import App from './App.tsx'
import Friends from './pages/Friends.tsx' import Friends from './pages/Friends.tsx'
import Blog from './pages/Blog.tsx' import Blog from './pages/Blog.tsx'
import CreatePost from './pages/CreatePost.tsx'
import Servers from './pages/Servers.tsx' import Servers from './pages/Servers.tsx'
import Forum from './pages/Forum.tsx' import Forum from './pages/Forum.tsx'
import AuthCallback from './pages/AuthCallback.tsx' import AuthCallback from './pages/AuthCallback.tsx'
@@ -28,6 +29,7 @@ createRoot(document.getElementById('root')!).render(
<Route path="/" element={<App />} /> <Route path="/" element={<App />} />
<Route path="/friends" element={<Friends />} /> <Route path="/friends" element={<Friends />} />
<Route path="/blog" element={<Blog />} /> <Route path="/blog" element={<Blog />} />
<Route path="/blog/create" element={<CreatePost />} />
<Route path="/servers" element={<Servers />} /> <Route path="/servers" element={<Servers />} />
<Route path="/forum" element={<Forum />} /> <Route path="/forum" element={<Forum />} />
<Route path="/auth/callback" element={<AuthCallback />} /> <Route path="/auth/callback" element={<AuthCallback />} />

View File

@@ -119,10 +119,36 @@ function Blog() {
<p style={{ <p style={{
fontSize: '1.3rem', fontSize: '1.3rem',
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
lineHeight: '1.6' lineHeight: '1.6',
marginBottom: '2rem'
}}> }}>
{t('blog.subtitle')} {t('blog.subtitle')}
</p> </p>
{/* Create Post Button */}
<Link
to="/blog/create"
style={{
display: 'inline-block',
background: 'var(--accent-color)',
color: 'white',
padding: '1rem 2rem',
borderRadius: '8px',
textDecoration: 'none',
fontWeight: 'bold',
fontSize: '1.1rem',
transition: 'transform 0.3s ease, box-shadow 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 12px var(--accent-shadow)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
Create New Post
</Link>
</div> </div>
</section> </section>

355
src/pages/CreatePost.tsx Normal file
View File

@@ -0,0 +1,355 @@
import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import BlogEditor from '../blog/BlogEditor';
import { uploadImage, createBlogPost } from '../blog/api';
import '../App.css';
function CreatePost() {
const navigate = useNavigate();
const [title, setTitle] = useState('');
const [coverImage, setCoverImage] = useState<string | null>(null);
const [coverImageKey, setCoverImageKey] = useState<string>('');
const [uploadProgress, setUploadProgress] = useState<number>(0);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleCoverImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
setUploadError('Please select an image file');
return;
}
// Show preview immediately
const reader = new FileReader();
reader.onload = (e) => {
setCoverImage(e.target?.result as string);
};
reader.readAsDataURL(file);
// Upload to S3
setIsUploading(true);
setUploadError('');
setUploadProgress(0);
try {
const { fileKey, url } = await uploadImage(file, (progress) => {
setUploadProgress(progress);
});
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);
} finally {
setIsUploading(false);
}
};
const handleSubmit = async () => {
if (!title.trim()) {
alert('Please enter a title');
return;
}
if (!coverImageKey) {
alert('Please upload a cover image');
return;
}
setIsSubmitting(true);
try {
// Get editor content
// Note: You'll need to export the editor content from the BlogEditor component
// For now, we'll use a placeholder
const content = JSON.stringify({ editorState: 'placeholder' });
const response = await createBlogPost(title, content, coverImageKey);
console.log('✅ Blog post created successfully:', response.postId);
alert('Blog post created successfully!');
// 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');
} finally {
setIsSubmitting(false);
}
};
return (
<Layout currentPage="blog">
<div style={{
maxWidth: '900px',
margin: '0 auto',
padding: '120px 2rem 80px'
}}>
{/* Header */}
<div style={{ marginBottom: '3rem' }}>
<h1 style={{
fontSize: '2.5rem',
fontWeight: 'bold',
color: 'var(--text-primary)',
marginBottom: '1rem'
}}>
Create New Post
</h1>
<p style={{
fontSize: '1.1rem',
color: 'var(--text-secondary)'
}}>
Share your thoughts with the community
</p>
</div>
{/* Title Input */}
<div style={{ marginBottom: '2rem' }}>
<label style={{
display: 'block',
fontSize: '1rem',
fontWeight: '600',
color: 'var(--text-primary)',
marginBottom: '0.5rem'
}}>
Title *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter your blog post title..."
style={{
width: '100%',
padding: '1rem',
fontSize: '1.2rem',
border: '2px solid var(--border-color)',
borderRadius: '8px',
background: 'var(--bg-card)',
color: 'var(--text-primary)',
outline: 'none',
transition: 'border-color 0.3s ease'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-color)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--border-color)';
}}
/>
</div>
{/* Cover Image Upload */}
<div style={{ marginBottom: '2rem' }}>
<label style={{
display: 'block',
fontSize: '1rem',
fontWeight: '600',
color: 'var(--text-primary)',
marginBottom: '0.5rem'
}}>
Cover Image *
</label>
<div style={{
border: '2px dashed var(--border-color)',
borderRadius: '8px',
padding: '2rem',
textAlign: 'center',
background: 'var(--bg-card)',
cursor: 'pointer',
transition: 'border-color 0.3s ease'
}}
onClick={() => fileInputRef.current?.click()}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-color)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-color)';
}}
>
{coverImage ? (
<div style={{ position: 'relative' }}>
<img
src={coverImage}
alt="Cover"
style={{
maxWidth: '100%',
maxHeight: '400px',
borderRadius: '8px'
}}
/>
{isUploading && (
<div style={{
position: 'absolute',
bottom: '10px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(0, 0, 0, 0.8)',
padding: '10px 20px',
borderRadius: '8px',
color: 'white',
minWidth: '200px'
}}>
<div style={{
background: 'rgba(255, 255, 255, 0.2)',
borderRadius: '4px',
overflow: 'hidden',
height: '6px',
marginBottom: '5px'
}}>
<div style={{
background: 'var(--accent-color, #4CAF50)',
height: '100%',
width: `${uploadProgress}%`,
transition: 'width 0.3s ease'
}} />
</div>
<div style={{ fontSize: '14px' }}>
Uploading... {Math.round(uploadProgress)}%
</div>
</div>
)}
</div>
) : (
<div>
<div style={{
fontSize: '3rem',
marginBottom: '1rem'
}}>
📷
</div>
<p style={{
color: 'var(--text-secondary)',
fontSize: '1rem'
}}>
Click to upload cover image
</p>
<p style={{
color: 'var(--text-secondary)',
fontSize: '0.9rem',
marginTop: '0.5rem'
}}>
PNG, JPG, GIF up to 10MB
</p>
</div>
)}
</div>
{uploadError && (
<div style={{
marginTop: '1rem',
padding: '1rem',
background: 'rgba(244, 67, 54, 0.1)',
border: '1px solid rgba(244, 67, 54, 0.3)',
borderRadius: '8px',
color: '#F44336'
}}>
{uploadError}
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleCoverImageChange}
style={{ display: 'none' }}
/>
</div>
{/* Content Editor */}
<div style={{ marginBottom: '2rem' }}>
<label style={{
display: 'block',
fontSize: '1rem',
fontWeight: '600',
color: 'var(--text-primary)',
marginBottom: '0.5rem'
}}>
Content *
</label>
<div style={{
border: '2px solid var(--border-color)',
borderRadius: '8px',
overflow: 'hidden',
background: 'var(--bg-card)'
}}>
<BlogEditor />
</div>
</div>
{/* Action Buttons */}
<div style={{
display: 'flex',
gap: '1rem',
justifyContent: 'flex-end'
}}>
<button
onClick={() => navigate('/blog')}
style={{
padding: '1rem 2rem',
fontSize: '1rem',
fontWeight: '600',
border: '2px solid var(--border-color)',
borderRadius: '8px',
background: 'transparent',
color: 'var(--text-primary)',
cursor: 'pointer',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-secondary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isSubmitting || isUploading}
style={{
padding: '1rem 2rem',
fontSize: '1rem',
fontWeight: '600',
border: 'none',
borderRadius: '8px',
background: isSubmitting || isUploading ? 'var(--bg-secondary)' : 'var(--accent-color)',
color: 'white',
cursor: isSubmitting || isUploading ? 'not-allowed' : 'pointer',
transition: 'all 0.3s ease',
opacity: isSubmitting || isUploading ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isSubmitting && !isUploading) {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 12px var(--accent-shadow)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{isSubmitting ? 'Publishing...' : 'Publish Post'}
</button>
</div>
</div>
</Layout>
);
}
export default CreatePost;