handlePointerDown(e, 's')}
+ style={{
+ position: 'absolute',
+ bottom: -5,
+ left: '50%',
+ transform: 'translateX(-50%)',
+ width: 10,
+ height: 10,
+ backgroundColor: '#4A90E2',
+ cursor: 's-resize',
+ pointerEvents: 'auto',
+ borderRadius: '50%',
+ }}
+ />
+
handlePointerDown(e, 'w')}
+ style={{
+ position: 'absolute',
+ top: '50%',
+ left: -5,
+ transform: 'translateY(-50%)',
+ width: 10,
+ height: 10,
+ backgroundColor: '#4A90E2',
+ cursor: 'w-resize',
+ pointerEvents: 'auto',
+ borderRadius: '50%',
+ }}
+ />
+
handlePointerDown(e, 'e')}
+ style={{
+ position: 'absolute',
+ top: '50%',
+ right: -5,
+ transform: 'translateY(-50%)',
+ width: 10,
+ height: 10,
+ backgroundColor: '#4A90E2',
+ cursor: 'e-resize',
+ pointerEvents: 'auto',
+ borderRadius: '50%',
+ }}
+ />
+
+ )
+}
diff --git a/src/editor/nodes/ImageNode.tsx b/src/editor/nodes/ImageNode.tsx
new file mode 100644
index 0000000..f3ef32c
--- /dev/null
+++ b/src/editor/nodes/ImageNode.tsx
@@ -0,0 +1,189 @@
+import type {
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ EditorConfig,
+ LexicalEditor,
+ LexicalNode,
+ NodeKey,
+ SerializedLexicalNode,
+ Spread,
+} from 'lexical'
+
+import { $applyNodeReplacement, DecoratorNode } from 'lexical'
+import { Suspense, lazy } from 'react'
+import type React from 'react'
+
+const ImageComponent = lazy(() => import('./ImageComponent'))
+
+export interface ImagePayload {
+ altText: string
+ height?: number
+ key?: NodeKey
+ src: string
+ width?: number
+}
+
+function convertImageElement(domNode: Node): null | DOMConversionOutput {
+ const img = domNode as HTMLImageElement
+ if (img.src.startsWith('file:///')) {
+ return null
+ }
+ const { alt: altText, src, width, height } = img
+ const node = $createImageNode({ altText, height, src, width })
+ return { node }
+}
+
+export type SerializedImageNode = Spread<
+ {
+ altText: string
+ height?: number
+ src: string
+ width?: number
+ },
+ SerializedLexicalNode
+>
+
+export class ImageNode extends DecoratorNode
{
+ __src: string
+ __altText: string
+ __width: 'inherit' | number
+ __height: 'inherit' | number
+
+ static getType(): string {
+ return 'image'
+ }
+
+ static clone(node: ImageNode): ImageNode {
+ return new ImageNode(
+ node.__src,
+ node.__altText,
+ node.__width,
+ node.__height,
+ node.__key
+ )
+ }
+
+ static importJSON(serializedNode: SerializedImageNode): ImageNode {
+ const { altText, height, width, src } = serializedNode
+ const node = $createImageNode({
+ altText,
+ height,
+ src,
+ width,
+ })
+ return node
+ }
+
+ exportDOM(): DOMExportOutput {
+ const element = document.createElement('img')
+ element.setAttribute('src', this.__src)
+ element.setAttribute('alt', this.__altText)
+ 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,
+ width?: 'inherit' | number,
+ height?: 'inherit' | number,
+ key?: NodeKey
+ ) {
+ super(key)
+ this.__src = src
+ this.__altText = altText
+ this.__width = width || 'inherit'
+ this.__height = height || 'inherit'
+ }
+
+ exportJSON(): SerializedImageNode {
+ return {
+ altText: this.getAltText(),
+ height: this.__height === 'inherit' ? 0 : this.__height,
+ src: this.getSrc(),
+ type: 'image',
+ version: 1,
+ width: this.__width === 'inherit' ? 0 : this.__width,
+ }
+ }
+
+ setWidthAndHeight(
+ width: 'inherit' | number,
+ height: 'inherit' | number
+ ): void {
+ const writable = this.getWritable()
+ writable.__width = width
+ writable.__height = height
+ }
+
+ // View
+
+ 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
+ }
+
+ decorate(_editor: LexicalEditor): React.JSX.Element {
+ return (
+
+
+
+ )
+ }
+}
+
+export function $createImageNode({
+ altText,
+ height,
+ src,
+ width,
+ key,
+}: ImagePayload): ImageNode {
+ return $applyNodeReplacement(
+ new ImageNode(src, altText, width, height, key)
+ )
+}
+
+export function $isImageNode(
+ node: LexicalNode | null | undefined
+): node is ImageNode {
+ return node instanceof ImageNode
+}
diff --git a/src/editor/plugins/ImagesPlugin.tsx b/src/editor/plugins/ImagesPlugin.tsx
new file mode 100644
index 0000000..96124e7
--- /dev/null
+++ b/src/editor/plugins/ImagesPlugin.tsx
@@ -0,0 +1,108 @@
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { $insertNodes, COMMAND_PRIORITY_HIGH } from 'lexical'
+import { useEffect } from 'react'
+import { $createImageNode, ImageNode } from '../nodes/ImageNode'
+import { INSERT_IMAGE_COMMAND, type InsertImagePayload } from './ImagesPluginCommands'
+
+export default function ImagesPlugin(): null {
+ const [editor] = useLexicalComposerContext()
+
+ useEffect(() => {
+ if (!editor.hasNodes([ImageNode])) {
+ throw new Error('ImagesPlugin: ImageNode not registered on editor')
+ }
+
+ return editor.registerCommand(
+ INSERT_IMAGE_COMMAND,
+ (payload) => {
+ const imageNode = $createImageNode(payload)
+ $insertNodes([imageNode])
+ return true
+ },
+ COMMAND_PRIORITY_HIGH
+ )
+ }, [editor])
+
+ // Handle drag and drop
+ useEffect(() => {
+ const handleDrop = (event: DragEvent) => {
+ const files = event.dataTransfer?.files
+ if (files && files.length > 0) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ Array.from(files).forEach((file) => {
+ if (file.type.startsWith('image/')) {
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ const src = e.target?.result as string
+ editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
+ altText: file.name,
+ src,
+ })
+ }
+ reader.readAsDataURL(file)
+ }
+ })
+ }
+ }
+
+ const handleDragOver = (event: DragEvent) => {
+ if (event.dataTransfer?.types.includes('Files')) {
+ event.preventDefault()
+ }
+ }
+
+ const editorElement = editor.getRootElement()
+ if (editorElement) {
+ editorElement.addEventListener('drop', handleDrop)
+ editorElement.addEventListener('dragover', handleDragOver)
+ }
+
+ return () => {
+ if (editorElement) {
+ editorElement.removeEventListener('drop', handleDrop)
+ editorElement.removeEventListener('dragover', handleDragOver)
+ }
+ }
+ }, [editor])
+
+ // Handle paste
+ useEffect(() => {
+ const handlePaste = (event: ClipboardEvent) => {
+ const items = event.clipboardData?.items
+ if (items) {
+ Array.from(items).forEach((item) => {
+ if (item.type.startsWith('image/')) {
+ event.preventDefault()
+ const file = item.getAsFile()
+ if (file) {
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ const src = e.target?.result as string
+ editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
+ altText: file.name,
+ src,
+ })
+ }
+ reader.readAsDataURL(file)
+ }
+ }
+ })
+ }
+ }
+
+ const editorElement = editor.getRootElement()
+ if (editorElement) {
+ editorElement.addEventListener('paste', handlePaste)
+ }
+
+ return () => {
+ if (editorElement) {
+ editorElement.removeEventListener('paste', handlePaste)
+ }
+ }
+ }, [editor])
+
+ return null
+}
diff --git a/src/editor/plugins/ImagesPluginCommands.ts b/src/editor/plugins/ImagesPluginCommands.ts
new file mode 100644
index 0000000..f1c8313
--- /dev/null
+++ b/src/editor/plugins/ImagesPluginCommands.ts
@@ -0,0 +1,7 @@
+import { createCommand, type LexicalCommand } from 'lexical'
+import type { ImagePayload } from '../nodes/ImageNode'
+
+export type InsertImagePayload = Readonly
+
+export const INSERT_IMAGE_COMMAND: LexicalCommand =
+ createCommand('INSERT_IMAGE_COMMAND')
diff --git a/src/editor/plugins/ToolbarPlugin.css b/src/editor/plugins/ToolbarPlugin.css
new file mode 100644
index 0000000..eb901e5
--- /dev/null
+++ b/src/editor/plugins/ToolbarPlugin.css
@@ -0,0 +1,100 @@
+.toolbar {
+ display: flex;
+ background: var(--bg-secondary);
+ padding: 8px 16px;
+ border-bottom: 1px solid var(--border-color);
+ align-items: center;
+ gap: 4px;
+ flex-wrap: wrap;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.toolbar-item {
+ border: 0;
+ display: flex;
+ background: none;
+ border-radius: 6px;
+ padding: 8px;
+ cursor: pointer;
+ vertical-align: middle;
+ color: var(--text-primary);
+ transition: background-color 0.2s;
+}
+
+.toolbar-item:hover:not(:disabled) {
+ background-color: var(--bg-primary);
+}
+
+.toolbar-item.active {
+ background-color: var(--accent-primary);
+ color: white;
+}
+
+.toolbar-item:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+.toolbar-item.spaced {
+ margin-right: 2px;
+}
+
+.toolbar-item.font-size {
+ width: 100px;
+ padding: 4px 8px;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ cursor: pointer;
+}
+
+.divider {
+ width: 1px;
+ background-color: var(--border-color);
+ margin: 0 8px;
+ height: 24px;
+}
+
+i.format {
+ background-size: contain;
+ display: inline-block;
+ height: 18px;
+ width: 18px;
+ vertical-align: -0.25em;
+ position: relative;
+}
+
+i.format.bold {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z'/%3E%3C/svg%3E");
+}
+
+i.format.italic {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z'/%3E%3C/svg%3E");
+}
+
+i.format.underline {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z'/%3E%3C/svg%3E");
+}
+
+i.format.strikethrough {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z'/%3E%3C/svg%3E");
+}
+
+i.format.left-align {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z'/%3E%3C/svg%3E");
+}
+
+i.format.center-align {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z'/%3E%3C/svg%3E");
+}
+
+i.format.right-align {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M3 21h18v-2H3v2zm6-4h12v-2H9v2zm-6-4h18v-2H3v2zm6-4h12V7H9v2zM3 3v2h18V3H3z'/%3E%3C/svg%3E");
+}
+
+i.format.justify-align {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M3 21h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18V7H3v2zm0-6v2h18V3H3z'/%3E%3C/svg%3E");
+}
diff --git a/src/editor/plugins/ToolbarPlugin.tsx b/src/editor/plugins/ToolbarPlugin.tsx
new file mode 100644
index 0000000..83fdcdb
--- /dev/null
+++ b/src/editor/plugins/ToolbarPlugin.tsx
@@ -0,0 +1,169 @@
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import {
+ $getSelection,
+ $isRangeSelection,
+ FORMAT_TEXT_COMMAND,
+ FORMAT_ELEMENT_COMMAND,
+} from 'lexical'
+import { $patchStyleText } from '@lexical/selection'
+import { useCallback, useEffect, useState } from 'react'
+import type React from 'react'
+import './ToolbarPlugin.css'
+
+const FONT_SIZE_OPTIONS = [
+ ['10px', '10px'],
+ ['12px', '12px'],
+ ['14px', '14px'],
+ ['16px', '16px'],
+ ['18px', '18px'],
+ ['20px', '20px'],
+ ['24px', '24px'],
+ ['30px', '30px'],
+ ['36px', '36px'],
+ ['48px', '48px'],
+]
+
+export default function ToolbarPlugin(): React.JSX.Element {
+ const [editor] = useLexicalComposerContext()
+ const [isBold, setIsBold] = useState(false)
+ const [isItalic, setIsItalic] = useState(false)
+ const [isUnderline, setIsUnderline] = useState(false)
+ const [isStrikethrough, setIsStrikethrough] = useState(false)
+ const [fontSize, setFontSize] = useState('16px')
+
+ const updateToolbar = useCallback(() => {
+ const selection = $getSelection()
+ if ($isRangeSelection(selection)) {
+ setIsBold(selection.hasFormat('bold'))
+ setIsItalic(selection.hasFormat('italic'))
+ setIsUnderline(selection.hasFormat('underline'))
+ setIsStrikethrough(selection.hasFormat('strikethrough'))
+ }
+ }, [])
+
+ useEffect(() => {
+ return editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ updateToolbar()
+ })
+ })
+ }, [editor, updateToolbar])
+
+ const applyStyleText = useCallback(
+ (styles: Record) => {
+ editor.update(() => {
+ const selection = $getSelection()
+ if ($isRangeSelection(selection)) {
+ $patchStyleText(selection, styles)
+ }
+ })
+ },
+ [editor]
+ )
+
+ const onFontSizeChange = useCallback(
+ (value: string) => {
+ setFontSize(value)
+ applyStyleText({ 'font-size': value })
+ },
+ [applyStyleText]
+ )
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/main.tsx b/src/main.tsx
index ee12b36..165424c 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -14,6 +14,7 @@ import Blog from './pages/Blog.tsx'
import Servers from './pages/Servers.tsx'
import Forum from './pages/Forum.tsx'
import AuthCallback from './pages/AuthCallback.tsx'
+import CreatePost from './pages/CreatePost.tsx'
createRoot(document.getElementById('root')!).render(
@@ -29,6 +30,7 @@ createRoot(document.getElementById('root')!).render(
} />
} />
} />
+ } />
} />
diff --git a/src/pages/CreatePost.css b/src/pages/CreatePost.css
new file mode 100644
index 0000000..101c147
--- /dev/null
+++ b/src/pages/CreatePost.css
@@ -0,0 +1,179 @@
+.create-post-container {
+ max-width: 1000px;
+ margin: 0 auto;
+ padding: 100px 2rem 4rem;
+}
+
+.create-post-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 2rem;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.create-post-header h1 {
+ font-size: 2rem;
+ color: var(--text-primary);
+ margin: 0;
+}
+
+.post-type-selector {
+ display: flex;
+ gap: 0.5rem;
+ background: var(--bg-secondary);
+ padding: 4px;
+ border-radius: 8px;
+}
+
+.post-type-selector button {
+ padding: 0.5rem 1rem;
+ border: none;
+ background: transparent;
+ color: var(--text-primary);
+ cursor: pointer;
+ border-radius: 6px;
+ font-size: 1rem;
+ transition: all 0.2s;
+}
+
+.post-type-selector button:hover {
+ background: var(--bg-primary);
+}
+
+.post-type-selector button.active {
+ background: var(--accent-primary);
+ color: white;
+}
+
+.create-post-form {
+ background: var(--bg-secondary);
+ padding: 2rem;
+ border-radius: 12px;
+ border: 1px solid var(--border-color);
+ margin-bottom: 2rem;
+}
+
+.form-group {
+ margin-bottom: 2rem;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: var(--text-primary);
+ font-weight: 600;
+ font-size: 1rem;
+}
+
+.title-input {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ font-size: 1.25rem;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ transition: border-color 0.2s;
+}
+
+.title-input:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+}
+
+.form-actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: flex-end;
+ margin-top: 2rem;
+}
+
+.btn-primary,
+.btn-secondary {
+ padding: 0.75rem 1.5rem;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ border: none;
+}
+
+.btn-primary {
+ background: var(--accent-primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--accent-secondary);
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+}
+
+.btn-secondary {
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+}
+
+.btn-secondary:hover {
+ background: var(--bg-secondary);
+}
+
+.editor-tips {
+ background: var(--bg-secondary);
+ padding: 1.5rem;
+ border-radius: 12px;
+ border: 1px solid var(--border-color);
+}
+
+.editor-tips h3 {
+ margin: 0 0 1rem 0;
+ color: var(--text-primary);
+ font-size: 1.1rem;
+}
+
+.editor-tips ul {
+ margin: 0;
+ padding-left: 1.5rem;
+ color: var(--text-secondary);
+}
+
+.editor-tips li {
+ margin-bottom: 0.5rem;
+ line-height: 1.6;
+}
+
+@media (max-width: 768px) {
+ .create-post-container {
+ padding: 80px 1rem 2rem;
+ }
+
+ .create-post-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .post-type-selector {
+ width: 100%;
+ }
+
+ .post-type-selector button {
+ flex: 1;
+ }
+
+ .create-post-form {
+ padding: 1rem;
+ }
+
+ .form-actions {
+ flex-direction: column;
+ }
+
+ .btn-primary,
+ .btn-secondary {
+ width: 100%;
+ }
+}
diff --git a/src/pages/CreatePost.tsx b/src/pages/CreatePost.tsx
new file mode 100644
index 0000000..1ca0765
--- /dev/null
+++ b/src/pages/CreatePost.tsx
@@ -0,0 +1,104 @@
+import { useState } from 'react'
+import Layout from '../components/Layout'
+import Editor from '../editor/Editor'
+import type { EditorState } from 'lexical'
+import './CreatePost.css'
+
+export default function CreatePost() {
+ const [title, setTitle] = useState('')
+ const [editorState, setEditorState] = useState(null)
+ const [postType, setPostType] = useState<'blog' | 'forum'>('blog')
+
+ const handleEditorChange = (newEditorState: EditorState) => {
+ setEditorState(newEditorState)
+ }
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!title.trim()) {
+ alert('Please enter a title')
+ return
+ }
+
+ if (!editorState) {
+ alert('Please write some content')
+ return
+ }
+
+ // Get the content as JSON
+ const contentJSON = editorState.toJSON()
+
+ console.log('Post Type:', postType)
+ console.log('Title:', title)
+ console.log('Content JSON:', contentJSON)
+
+ // Here you would send this to your API
+ alert(`${postType === 'blog' ? 'Blog' : 'Forum'} post created! Check console for data.`)
+ }
+
+ return (
+
+
+
+
Create New Post
+
+
+
+
+
+
+
+
+
+
💡 Editor Tips
+
+ - Use the toolbar to format your text (bold, italic, underline, etc.)
+ - Drag & drop images directly into the editor
+ - Copy and paste images from your clipboard
+ - Click on images to select them and drag corners to resize
+ - Supported image formats: JPG, PNG, GIF
+
+
+
+
+ )
+}