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'; + }; }; }