From e93528b13c8be1814657d8b8fbe73228de1afb97 Mon Sep 17 00:00:00 2001 From: Shreyash Gupta <48386323+shreyashkgupta@users.noreply.github.com> Date: Thu, 12 Dec 2024 22:17:34 +0530 Subject: [PATCH 1/2] feat: Spacer block --- .../src/components/blocks/SpacerBlock.tsx | 59 +++++++++++++++++++ .../src/components/editor/AIRewriteButton.tsx | 15 +++++ .../src/components/editor/AddBlockButton.tsx | 2 + .../components/editor/BlockFormatToolbar.tsx | 2 +- .../src/lib/renderers/BlockRenderer.tsx | 9 +++ packages/akiradocs/src/types/Block.ts | 2 +- .../src/app/editMode/[...slug]/page.tsx | 5 ++ .../src/components/blocks/SpacerBlock.tsx | 59 +++++++++++++++++++ .../src/components/editor/AIRewriteButton.tsx | 15 +++++ .../src/components/editor/AddBlockButton.tsx | 2 + .../components/editor/BlockFormatToolbar.tsx | 2 +- .../src/lib/renderers/BlockRenderer.tsx | 9 +++ packages/editor/src/types/Block.ts | 2 +- 13 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 packages/akiradocs/src/components/blocks/SpacerBlock.tsx create mode 100644 packages/editor/src/components/blocks/SpacerBlock.tsx diff --git a/packages/akiradocs/src/components/blocks/SpacerBlock.tsx b/packages/akiradocs/src/components/blocks/SpacerBlock.tsx new file mode 100644 index 0000000..5fdd01e --- /dev/null +++ b/packages/akiradocs/src/components/blocks/SpacerBlock.tsx @@ -0,0 +1,59 @@ +import { cn } from '@/lib/utils' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useState } from 'react' + +interface SpacerBlockProps { + size?: 'small' | 'medium' | 'large' | 'xlarge' + isEditing?: boolean + onUpdate?: (size: string) => void +} + +const spacingSizes = { + small: 'h-4', + medium: 'h-8', + large: 'h-16', + xlarge: 'h-24' +} + +export function SpacerBlock({ size = 'medium', isEditing, onUpdate }: SpacerBlockProps) { + const [isFocused, setIsFocused] = useState(false) + + if (!isEditing) { + return
+ } + + return ( +
setIsFocused(true)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setIsFocused(false) + } + }} + > +
+ {isFocused && ( + + )} +
+ ) +} \ No newline at end of file diff --git a/packages/akiradocs/src/components/editor/AIRewriteButton.tsx b/packages/akiradocs/src/components/editor/AIRewriteButton.tsx index eb1c654..33e9954 100644 --- a/packages/akiradocs/src/components/editor/AIRewriteButton.tsx +++ b/packages/akiradocs/src/components/editor/AIRewriteButton.tsx @@ -193,6 +193,13 @@ const blockStyles = { label: 'Descriptive', prompt: 'Rewrite the API reference to be more descriptive and engaging.' } + ], + spacer: [ + { + value: 'default', + label: 'Default', + prompt: 'No rewriting options available for spacer' + } ] } as const; @@ -213,6 +220,10 @@ export function AIRewriteButton({ onRewrite, blockType, isRewriting }: AIRewrite }; const handleRewrite = async () => { + if (blockType === 'spacer') { + return; + } + try { await onRewrite(style) } catch (error) { @@ -224,6 +235,10 @@ export function AIRewriteButton({ onRewrite, blockType, isRewriting }: AIRewrite } } + if (blockType === 'spacer') { + return null; + } + return ( diff --git a/packages/akiradocs/src/components/editor/AddBlockButton.tsx b/packages/akiradocs/src/components/editor/AddBlockButton.tsx index 00a1770..5eb1ffd 100644 --- a/packages/akiradocs/src/components/editor/AddBlockButton.tsx +++ b/packages/akiradocs/src/components/editor/AddBlockButton.tsx @@ -29,6 +29,7 @@ import { Music, File, CheckSquare, + ArrowUpDown, } from 'lucide-react' interface AddBlockButtonProps { @@ -83,6 +84,7 @@ export const AddBlockButton = forwardRef< { type: 'audio', icon: , label: 'Audio', description: 'Embed audio content.', group: 'Media' }, { type: 'file', icon: , label: 'File', description: 'Upload or link to a file.', group: 'Media' }, { type: 'checkList', icon: , label: 'To-do list', description: 'Track tasks with a to-do list.', group: 'Basic' }, + { type: 'spacer', icon: , label: 'Spacing', description: 'Add vertical space between blocks.', group: 'Basic' }, ] const filteredOptions = blockOptions.filter((option) => diff --git a/packages/akiradocs/src/components/editor/BlockFormatToolbar.tsx b/packages/akiradocs/src/components/editor/BlockFormatToolbar.tsx index 8a70ee5..0d16b41 100644 --- a/packages/akiradocs/src/components/editor/BlockFormatToolbar.tsx +++ b/packages/akiradocs/src/components/editor/BlockFormatToolbar.tsx @@ -120,7 +120,7 @@ export function BlockFormatToolbar({ audioContent, onAudioMetadataChange, }: BlockFormatToolbarProps) { - if (blockType === 'file') { + if (blockType === 'file' || blockType === 'spacer') { return null; } diff --git a/packages/akiradocs/src/lib/renderers/BlockRenderer.tsx b/packages/akiradocs/src/lib/renderers/BlockRenderer.tsx index b640646..e81b244 100644 --- a/packages/akiradocs/src/lib/renderers/BlockRenderer.tsx +++ b/packages/akiradocs/src/lib/renderers/BlockRenderer.tsx @@ -22,6 +22,7 @@ import { Table } from '@/components/blocks/TableBlock' import { VideoBlock } from "@/components/blocks/VideoBlock" import { AudioBlock } from "@/components/blocks/AudioBlock" import { FileBlock } from "@/components/blocks/FileBlock" +import { SpacerBlock } from "@/components/blocks/SpacerBlock" interface ImageBlockContent { url: string; @@ -234,6 +235,14 @@ export function BlockRenderer({ block, isEditing, onUpdate }: BlockRendererProps onUpdate={(content) => onUpdate?.(block.id, content)} /> ); + case 'spacer': + return ( + onUpdate?.(block.id, size)} + /> + ); default: return null } diff --git a/packages/akiradocs/src/types/Block.ts b/packages/akiradocs/src/types/Block.ts index 61f1b7a..ec9f5fc 100644 --- a/packages/akiradocs/src/types/Block.ts +++ b/packages/akiradocs/src/types/Block.ts @@ -12,8 +12,8 @@ export type BlockType = | 'video' | 'audio' | 'file' - // | 'emoji' | 'callout' + | 'spacer' | 'apiReference'; export interface Block { diff --git a/packages/editor/src/app/editMode/[...slug]/page.tsx b/packages/editor/src/app/editMode/[...slug]/page.tsx index 116dbff..03e3046 100644 --- a/packages/editor/src/app/editMode/[...slug]/page.tsx +++ b/packages/editor/src/app/editMode/[...slug]/page.tsx @@ -157,6 +157,11 @@ export default function ArticleEditorContent({ params }: { params: Promise<{ slu metadata: {} } + // Add default content for spacer blocks + if (newBlock.type === 'spacer') { + newBlock.content = 'medium' + } + if (newBlock.type === 'table') { newBlock.metadata = { headers: ['Column 1', 'Column 2'], diff --git a/packages/editor/src/components/blocks/SpacerBlock.tsx b/packages/editor/src/components/blocks/SpacerBlock.tsx new file mode 100644 index 0000000..5fdd01e --- /dev/null +++ b/packages/editor/src/components/blocks/SpacerBlock.tsx @@ -0,0 +1,59 @@ +import { cn } from '@/lib/utils' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useState } from 'react' + +interface SpacerBlockProps { + size?: 'small' | 'medium' | 'large' | 'xlarge' + isEditing?: boolean + onUpdate?: (size: string) => void +} + +const spacingSizes = { + small: 'h-4', + medium: 'h-8', + large: 'h-16', + xlarge: 'h-24' +} + +export function SpacerBlock({ size = 'medium', isEditing, onUpdate }: SpacerBlockProps) { + const [isFocused, setIsFocused] = useState(false) + + if (!isEditing) { + return
+ } + + return ( +
setIsFocused(true)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setIsFocused(false) + } + }} + > +
+ {isFocused && ( + + )} +
+ ) +} \ No newline at end of file diff --git a/packages/editor/src/components/editor/AIRewriteButton.tsx b/packages/editor/src/components/editor/AIRewriteButton.tsx index eb1c654..33e9954 100644 --- a/packages/editor/src/components/editor/AIRewriteButton.tsx +++ b/packages/editor/src/components/editor/AIRewriteButton.tsx @@ -193,6 +193,13 @@ const blockStyles = { label: 'Descriptive', prompt: 'Rewrite the API reference to be more descriptive and engaging.' } + ], + spacer: [ + { + value: 'default', + label: 'Default', + prompt: 'No rewriting options available for spacer' + } ] } as const; @@ -213,6 +220,10 @@ export function AIRewriteButton({ onRewrite, blockType, isRewriting }: AIRewrite }; const handleRewrite = async () => { + if (blockType === 'spacer') { + return; + } + try { await onRewrite(style) } catch (error) { @@ -224,6 +235,10 @@ export function AIRewriteButton({ onRewrite, blockType, isRewriting }: AIRewrite } } + if (blockType === 'spacer') { + return null; + } + return ( diff --git a/packages/editor/src/components/editor/AddBlockButton.tsx b/packages/editor/src/components/editor/AddBlockButton.tsx index 00a1770..5eb1ffd 100644 --- a/packages/editor/src/components/editor/AddBlockButton.tsx +++ b/packages/editor/src/components/editor/AddBlockButton.tsx @@ -29,6 +29,7 @@ import { Music, File, CheckSquare, + ArrowUpDown, } from 'lucide-react' interface AddBlockButtonProps { @@ -83,6 +84,7 @@ export const AddBlockButton = forwardRef< { type: 'audio', icon: , label: 'Audio', description: 'Embed audio content.', group: 'Media' }, { type: 'file', icon: , label: 'File', description: 'Upload or link to a file.', group: 'Media' }, { type: 'checkList', icon: , label: 'To-do list', description: 'Track tasks with a to-do list.', group: 'Basic' }, + { type: 'spacer', icon: , label: 'Spacing', description: 'Add vertical space between blocks.', group: 'Basic' }, ] const filteredOptions = blockOptions.filter((option) => diff --git a/packages/editor/src/components/editor/BlockFormatToolbar.tsx b/packages/editor/src/components/editor/BlockFormatToolbar.tsx index 8a70ee5..0d16b41 100644 --- a/packages/editor/src/components/editor/BlockFormatToolbar.tsx +++ b/packages/editor/src/components/editor/BlockFormatToolbar.tsx @@ -120,7 +120,7 @@ export function BlockFormatToolbar({ audioContent, onAudioMetadataChange, }: BlockFormatToolbarProps) { - if (blockType === 'file') { + if (blockType === 'file' || blockType === 'spacer') { return null; } diff --git a/packages/editor/src/lib/renderers/BlockRenderer.tsx b/packages/editor/src/lib/renderers/BlockRenderer.tsx index ba5d2a9..6117c55 100644 --- a/packages/editor/src/lib/renderers/BlockRenderer.tsx +++ b/packages/editor/src/lib/renderers/BlockRenderer.tsx @@ -22,6 +22,7 @@ import { Table } from '@/components/blocks/TableBlock' import { VideoBlock } from "@/components/blocks/VideoBlock" import { AudioBlock } from "@/components/blocks/AudioBlock" import { FileBlock } from "@/components/blocks/FileBlock" +import { SpacerBlock } from "@/components/blocks/SpacerBlock" interface ImageBlockContent { url: string; @@ -230,6 +231,14 @@ export function BlockRenderer({ block, isEditing, onUpdate }: BlockRendererProps onUpdate={(content) => onUpdate?.(block.id, content)} /> ); + case 'spacer': + return ( + onUpdate?.(block.id, size)} + /> + ); default: return null } diff --git a/packages/editor/src/types/Block.ts b/packages/editor/src/types/Block.ts index 61f1b7a..ec9f5fc 100644 --- a/packages/editor/src/types/Block.ts +++ b/packages/editor/src/types/Block.ts @@ -12,8 +12,8 @@ export type BlockType = | 'video' | 'audio' | 'file' - // | 'emoji' | 'callout' + | 'spacer' | 'apiReference'; export interface Block { From 35b3c9a0a14b013ddd9a9568c23c7cd0cd909156 Mon Sep 17 00:00:00 2001 From: Shreyash Gupta <48386323+shreyashkgupta@users.noreply.github.com> Date: Fri, 13 Dec 2024 00:46:25 +0530 Subject: [PATCH 2/2] feat: Button block --- .../src/components/blocks/ButtonBlock.tsx | 103 ++++++++++++++++++ .../src/components/blocks/SortableBlock.tsx | 21 ++++ .../src/components/editor/AIRewriteButton.tsx | 12 ++ .../src/components/editor/AddBlockButton.tsx | 9 +- .../components/editor/BlockFormatToolbar.tsx | 97 ++++++++++++++++- .../src/lib/renderers/BlockRenderer.tsx | 28 +++-- packages/akiradocs/src/types/Block.ts | 9 +- .../src/app/editMode/[...slug]/page.tsx | 12 ++ .../src/components/blocks/ButtonBlock.tsx | 103 ++++++++++++++++++ .../src/components/blocks/SortableBlock.tsx | 21 ++++ .../src/components/editor/AIRewriteButton.tsx | 12 ++ .../src/components/editor/AddBlockButton.tsx | 9 +- .../components/editor/BlockFormatToolbar.tsx | 97 ++++++++++++++++- .../src/lib/renderers/BlockRenderer.tsx | 20 ++-- packages/editor/src/types/Block.ts | 9 +- 15 files changed, 520 insertions(+), 42 deletions(-) create mode 100644 packages/akiradocs/src/components/blocks/ButtonBlock.tsx create mode 100644 packages/editor/src/components/blocks/ButtonBlock.tsx diff --git a/packages/akiradocs/src/components/blocks/ButtonBlock.tsx b/packages/akiradocs/src/components/blocks/ButtonBlock.tsx new file mode 100644 index 0000000..a54e8b1 --- /dev/null +++ b/packages/akiradocs/src/components/blocks/ButtonBlock.tsx @@ -0,0 +1,103 @@ +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { useState } from "react" + +interface ButtonBlockProps { + content: string + metadata?: { + buttonUrl?: string + buttonStyle?: { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' + size?: 'default' | 'sm' | 'lg' + radius?: 'none' | 'sm' | 'md' | 'lg' | 'full' + } + } + isEditing?: boolean + onUpdate?: (content: string) => void + align?: 'left' | 'center' | 'right' +} + +export function ButtonBlock({ + content, + metadata, + isEditing, + onUpdate, + align = 'left' +}: ButtonBlockProps) { + const [isFocused, setIsFocused] = useState(false); + const buttonStyle = metadata?.buttonStyle || {} + const url = metadata?.buttonUrl || '#' + + const buttonClasses = cn( + "relative", + { + 'rounded-none': buttonStyle.radius === 'none', + 'rounded-sm': buttonStyle.radius === 'sm', + 'rounded-md': buttonStyle.radius === 'md', + 'rounded-lg': buttonStyle.radius === 'lg', + 'rounded-full': buttonStyle.radius === 'full', + 'w-full': buttonStyle.size === 'lg', + 'w-24': buttonStyle.size === 'sm', + 'w-auto': buttonStyle.size === 'default' + } + ) + + const wrapperClasses = cn( + 'w-full', + { + 'text-left': align === 'left', + 'text-center': align === 'center', + 'text-right': align === 'right' + } + ) + + if (isEditing) { + return ( +
+
+ + onUpdate?.(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + className={cn( + buttonVariants({ + variant: buttonStyle.variant || 'default', + size: buttonStyle.size || 'default' + }), + buttonClasses, + "absolute inset-0 border-0 outline-none", + !isFocused && "opacity-0" + )} + placeholder="Button text..." + /> +
+
+ ) + } + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/packages/akiradocs/src/components/blocks/SortableBlock.tsx b/packages/akiradocs/src/components/blocks/SortableBlock.tsx index 9afa175..020e810 100644 --- a/packages/akiradocs/src/components/blocks/SortableBlock.tsx +++ b/packages/akiradocs/src/components/blocks/SortableBlock.tsx @@ -35,6 +35,12 @@ interface SortableBlockProps { align?: 'left' | 'center' | 'right' type?: 'info' | 'warning' | 'success' | 'error' title?: string + buttonUrl?: string + buttonStyle?: { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' + size?: 'default' | 'sm' | 'lg' + radius?: 'none' | 'sm' | 'md' | 'lg' | 'full' + } } } updateBlock: (id: string, content: string) => void @@ -514,6 +520,21 @@ export function SortableBlock({ updateBlock(block.id, JSON.stringify(updatedContent)); } }} + showButtonControls={block.type === 'button'} + buttonMetadata={{ + url: block.metadata?.buttonUrl, + style: block.metadata?.buttonStyle + }} + onButtonMetadataChange={(metadata) => { + updateBlockMetadata(block.id, { + ...block.metadata, + buttonUrl: metadata.buttonUrl, + buttonStyle: { + ...block.metadata?.buttonStyle, + ...metadata.buttonStyle + } + }) + }} /> )} , label: 'File', description: 'Upload or link to a file.', group: 'Media' }, { type: 'checkList', icon: , label: 'To-do list', description: 'Track tasks with a to-do list.', group: 'Basic' }, { type: 'spacer', icon: , label: 'Spacing', description: 'Add vertical space between blocks.', group: 'Basic' }, + { type: 'button', icon: , label: 'Button', description: 'Add a clickable button with link.', group: 'Basic' }, ] const filteredOptions = blockOptions.filter((option) => diff --git a/packages/akiradocs/src/components/editor/BlockFormatToolbar.tsx b/packages/akiradocs/src/components/editor/BlockFormatToolbar.tsx index 0d16b41..e575d98 100644 --- a/packages/akiradocs/src/components/editor/BlockFormatToolbar.tsx +++ b/packages/akiradocs/src/components/editor/BlockFormatToolbar.tsx @@ -76,6 +76,23 @@ interface BlockFormatToolbarProps { caption: string; alignment: 'left' | 'center' | 'right'; }>) => void; + showButtonControls?: boolean; + buttonMetadata?: { + url?: string; + style?: { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg'; + radius?: 'none' | 'sm' | 'md' | 'lg' | 'full'; + }; + }; + onButtonMetadataChange?: (metadata: Partial<{ + buttonUrl: string; + buttonStyle: { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg'; + radius?: 'none' | 'sm' | 'md' | 'lg' | 'full'; + }; + }>) => void; } export function BlockFormatToolbar({ @@ -119,6 +136,9 @@ export function BlockFormatToolbar({ showAudioControls = false, audioContent, onAudioMetadataChange, + showButtonControls = false, + buttonMetadata, + onButtonMetadataChange, }: BlockFormatToolbarProps) { if (blockType === 'file' || blockType === 'spacer') { return null; @@ -133,7 +153,7 @@ export function BlockFormatToolbar({ "bg-popover border shadow-md rounded-md", className )}> - {!showImageControls && !showCodeControls && !showVideoControls && !showAudioControls && blockType !== 'table' && ( + {!showImageControls && !showCodeControls && !showVideoControls && !showAudioControls && !showButtonControls && blockType !== 'table' && ( <> )} + {showButtonControls && ( + <> + + + onButtonMetadataChange?.({ buttonUrl: e.target.value })} + placeholder="URL" + className="h-7 w-32 text-xs" + /> + + + + + + + + )} + {!showImageControls && !showVideoControls && !showAudioControls && ( <> {!showCalloutControls && blockType !== 'table' && } diff --git a/packages/akiradocs/src/lib/renderers/BlockRenderer.tsx b/packages/akiradocs/src/lib/renderers/BlockRenderer.tsx index e81b244..6247349 100644 --- a/packages/akiradocs/src/lib/renderers/BlockRenderer.tsx +++ b/packages/akiradocs/src/lib/renderers/BlockRenderer.tsx @@ -6,23 +6,15 @@ import { List } from "@/components/blocks/ListBlock" import { Blockquote } from "@/components/blocks/BlockquoteBlock" import { Divider } from "@/components/blocks/DividerBlock" import { CodeBlock } from "@/components/blocks/CodeBlock" -// import { Image } from '../blocks/Image' -// import { Table } from '../blocks/Table' -// import { ToggleList } from '../blocks/ToggleList' import { CheckList } from "@/components/blocks/CheckListBlock" -// import { Video } from '../blocks/Video' -// import { Audio } from '../blocks/Audio' -// import { File } from '../blocks/File' -// import { Emoji } from '../blocks/Emoji' import { Callout } from "@/components/blocks/CalloutBlock" -import { cn } from '@/lib/utils' -import { ErrorBoundary } from 'react-error-boundary' import { ImageBlock } from "@/components/blocks/ImageBlock" import { Table } from '@/components/blocks/TableBlock' import { VideoBlock } from "@/components/blocks/VideoBlock" import { AudioBlock } from "@/components/blocks/AudioBlock" import { FileBlock } from "@/components/blocks/FileBlock" import { SpacerBlock } from "@/components/blocks/SpacerBlock" +import { ButtonBlock } from "@/components/blocks/ButtonBlock" interface ImageBlockContent { url: string; @@ -65,20 +57,16 @@ export function BlockRenderer({ block, isEditing, onUpdate }: BlockRendererProps ); case 'heading': - const hasStrong = block.content.includes(''); return ( onUpdate?.(block.id, content)} > - {hasStrong ? block.content.replace(/<\/?strong>/g, '') : block.content} + {block.content} ); case 'list': @@ -243,6 +231,16 @@ export function BlockRenderer({ block, isEditing, onUpdate }: BlockRendererProps onUpdate={(size) => onUpdate?.(block.id, size)} /> ); + case 'button': + return ( + onUpdate?.(block.id, content)} + align={block.metadata?.align} + /> + ); default: return null } diff --git a/packages/akiradocs/src/types/Block.ts b/packages/akiradocs/src/types/Block.ts index ec9f5fc..044cc58 100644 --- a/packages/akiradocs/src/types/Block.ts +++ b/packages/akiradocs/src/types/Block.ts @@ -14,7 +14,8 @@ export type BlockType = | 'file' | 'callout' | 'spacer' - | 'apiReference'; + | 'apiReference' + | 'button'; export interface Block { id: string; @@ -44,6 +45,12 @@ export interface Block { align?: 'left' | 'center' | 'right'; // For alignment type?: 'info' | 'warning' | 'success' | 'error'; // For callouts title?: string; // For callouts + buttonUrl?: string; + buttonStyle?: { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg'; + radius?: 'none' | 'sm' | 'md' | 'lg' | 'full'; + }; }; } diff --git a/packages/editor/src/app/editMode/[...slug]/page.tsx b/packages/editor/src/app/editMode/[...slug]/page.tsx index 03e3046..d43e4b1 100644 --- a/packages/editor/src/app/editMode/[...slug]/page.tsx +++ b/packages/editor/src/app/editMode/[...slug]/page.tsx @@ -173,6 +173,18 @@ export default function ArticleEditorContent({ params }: { params: Promise<{ slu newBlock.content = JSON.stringify([{ text: '', checked: false }]); } + if (newBlock.type === 'button') { + newBlock.content = 'Click me' + newBlock.metadata = { + buttonUrl: '#', + buttonStyle: { + variant: 'default', + size: 'default', + radius: 'md' + } + } + } + if (afterId === 'new') { setBlocks([newBlock]) } else { diff --git a/packages/editor/src/components/blocks/ButtonBlock.tsx b/packages/editor/src/components/blocks/ButtonBlock.tsx new file mode 100644 index 0000000..a54e8b1 --- /dev/null +++ b/packages/editor/src/components/blocks/ButtonBlock.tsx @@ -0,0 +1,103 @@ +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { useState } from "react" + +interface ButtonBlockProps { + content: string + metadata?: { + buttonUrl?: string + buttonStyle?: { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' + size?: 'default' | 'sm' | 'lg' + radius?: 'none' | 'sm' | 'md' | 'lg' | 'full' + } + } + isEditing?: boolean + onUpdate?: (content: string) => void + align?: 'left' | 'center' | 'right' +} + +export function ButtonBlock({ + content, + metadata, + isEditing, + onUpdate, + align = 'left' +}: ButtonBlockProps) { + const [isFocused, setIsFocused] = useState(false); + const buttonStyle = metadata?.buttonStyle || {} + const url = metadata?.buttonUrl || '#' + + const buttonClasses = cn( + "relative", + { + 'rounded-none': buttonStyle.radius === 'none', + 'rounded-sm': buttonStyle.radius === 'sm', + 'rounded-md': buttonStyle.radius === 'md', + 'rounded-lg': buttonStyle.radius === 'lg', + 'rounded-full': buttonStyle.radius === 'full', + 'w-full': buttonStyle.size === 'lg', + 'w-24': buttonStyle.size === 'sm', + 'w-auto': buttonStyle.size === 'default' + } + ) + + const wrapperClasses = cn( + 'w-full', + { + 'text-left': align === 'left', + 'text-center': align === 'center', + 'text-right': align === 'right' + } + ) + + if (isEditing) { + return ( +
+
+ + onUpdate?.(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + className={cn( + buttonVariants({ + variant: buttonStyle.variant || 'default', + size: buttonStyle.size || 'default' + }), + buttonClasses, + "absolute inset-0 border-0 outline-none", + !isFocused && "opacity-0" + )} + placeholder="Button text..." + /> +
+
+ ) + } + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/packages/editor/src/components/blocks/SortableBlock.tsx b/packages/editor/src/components/blocks/SortableBlock.tsx index 9afa175..020e810 100644 --- a/packages/editor/src/components/blocks/SortableBlock.tsx +++ b/packages/editor/src/components/blocks/SortableBlock.tsx @@ -35,6 +35,12 @@ interface SortableBlockProps { align?: 'left' | 'center' | 'right' type?: 'info' | 'warning' | 'success' | 'error' title?: string + buttonUrl?: string + buttonStyle?: { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' + size?: 'default' | 'sm' | 'lg' + radius?: 'none' | 'sm' | 'md' | 'lg' | 'full' + } } } updateBlock: (id: string, content: string) => void @@ -514,6 +520,21 @@ export function SortableBlock({ updateBlock(block.id, JSON.stringify(updatedContent)); } }} + showButtonControls={block.type === 'button'} + buttonMetadata={{ + url: block.metadata?.buttonUrl, + style: block.metadata?.buttonStyle + }} + onButtonMetadataChange={(metadata) => { + updateBlockMetadata(block.id, { + ...block.metadata, + buttonUrl: metadata.buttonUrl, + buttonStyle: { + ...block.metadata?.buttonStyle, + ...metadata.buttonStyle + } + }) + }} /> )} , label: 'File', description: 'Upload or link to a file.', group: 'Media' }, { type: 'checkList', icon: , label: 'To-do list', description: 'Track tasks with a to-do list.', group: 'Basic' }, { type: 'spacer', icon: , label: 'Spacing', description: 'Add vertical space between blocks.', group: 'Basic' }, + { type: 'button', icon: , label: 'Button', description: 'Add a clickable button with link.', group: 'Basic' }, ] const filteredOptions = blockOptions.filter((option) => diff --git a/packages/editor/src/components/editor/BlockFormatToolbar.tsx b/packages/editor/src/components/editor/BlockFormatToolbar.tsx index 0d16b41..e575d98 100644 --- a/packages/editor/src/components/editor/BlockFormatToolbar.tsx +++ b/packages/editor/src/components/editor/BlockFormatToolbar.tsx @@ -76,6 +76,23 @@ interface BlockFormatToolbarProps { caption: string; alignment: 'left' | 'center' | 'right'; }>) => void; + showButtonControls?: boolean; + buttonMetadata?: { + url?: string; + style?: { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg'; + radius?: 'none' | 'sm' | 'md' | 'lg' | 'full'; + }; + }; + onButtonMetadataChange?: (metadata: Partial<{ + buttonUrl: string; + buttonStyle: { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg'; + radius?: 'none' | 'sm' | 'md' | 'lg' | 'full'; + }; + }>) => void; } export function BlockFormatToolbar({ @@ -119,6 +136,9 @@ export function BlockFormatToolbar({ showAudioControls = false, audioContent, onAudioMetadataChange, + showButtonControls = false, + buttonMetadata, + onButtonMetadataChange, }: BlockFormatToolbarProps) { if (blockType === 'file' || blockType === 'spacer') { return null; @@ -133,7 +153,7 @@ export function BlockFormatToolbar({ "bg-popover border shadow-md rounded-md", className )}> - {!showImageControls && !showCodeControls && !showVideoControls && !showAudioControls && blockType !== 'table' && ( + {!showImageControls && !showCodeControls && !showVideoControls && !showAudioControls && !showButtonControls && blockType !== 'table' && ( <> )} + {showButtonControls && ( + <> + + + onButtonMetadataChange?.({ buttonUrl: e.target.value })} + placeholder="URL" + className="h-7 w-32 text-xs" + /> + + + + + + + + )} + {!showImageControls && !showVideoControls && !showAudioControls && ( <> {!showCalloutControls && blockType !== 'table' && } diff --git a/packages/editor/src/lib/renderers/BlockRenderer.tsx b/packages/editor/src/lib/renderers/BlockRenderer.tsx index 6117c55..6247349 100644 --- a/packages/editor/src/lib/renderers/BlockRenderer.tsx +++ b/packages/editor/src/lib/renderers/BlockRenderer.tsx @@ -6,23 +6,15 @@ import { List } from "@/components/blocks/ListBlock" import { Blockquote } from "@/components/blocks/BlockquoteBlock" import { Divider } from "@/components/blocks/DividerBlock" import { CodeBlock } from "@/components/blocks/CodeBlock" -// import { Image } from '../blocks/Image' -// import { Table } from '../blocks/Table' -// import { ToggleList } from '../blocks/ToggleList' import { CheckList } from "@/components/blocks/CheckListBlock" -// import { Video } from '../blocks/Video' -// import { Audio } from '../blocks/Audio' -// import { File } from '../blocks/File' -// import { Emoji } from '../blocks/Emoji' import { Callout } from "@/components/blocks/CalloutBlock" -import { cn } from '@/lib/utils' -import { ErrorBoundary } from 'react-error-boundary' import { ImageBlock } from "@/components/blocks/ImageBlock" import { Table } from '@/components/blocks/TableBlock' import { VideoBlock } from "@/components/blocks/VideoBlock" import { AudioBlock } from "@/components/blocks/AudioBlock" import { FileBlock } from "@/components/blocks/FileBlock" import { SpacerBlock } from "@/components/blocks/SpacerBlock" +import { ButtonBlock } from "@/components/blocks/ButtonBlock" interface ImageBlockContent { url: string; @@ -239,6 +231,16 @@ export function BlockRenderer({ block, isEditing, onUpdate }: BlockRendererProps onUpdate={(size) => onUpdate?.(block.id, size)} /> ); + case 'button': + return ( + onUpdate?.(block.id, content)} + align={block.metadata?.align} + /> + ); default: return null } diff --git a/packages/editor/src/types/Block.ts b/packages/editor/src/types/Block.ts index ec9f5fc..044cc58 100644 --- a/packages/editor/src/types/Block.ts +++ b/packages/editor/src/types/Block.ts @@ -14,7 +14,8 @@ export type BlockType = | 'file' | 'callout' | 'spacer' - | 'apiReference'; + | 'apiReference' + | 'button'; export interface Block { id: string; @@ -44,6 +45,12 @@ export interface Block { align?: 'left' | 'center' | 'right'; // For alignment type?: 'info' | 'warning' | 'success' | 'error'; // For callouts title?: string; // For callouts + buttonUrl?: string; + buttonStyle?: { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg'; + radius?: 'none' | 'sm' | 'md' | 'lg' | 'full'; + }; }; }