Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions src/app/editor/EditorWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import React, { useState } from 'react';
import React, { useId, useState } from 'react';
import dynamic from 'next/dynamic';
import { sanitizeHtml } from '@/utils/sanitize';

Expand All @@ -17,16 +17,29 @@ const RichContentEditor = dynamic(
export function EditorWorkspace() {
const [content, setContent] = useState('<p>Start editing...</p>');
const [isPreviewMode, setIsPreviewMode] = useState(false);
const workspaceTitleId = useId();
const editorPanelId = useId();
const previewPanelId = useId();
const editorHelpId = useId();
const liveRegionId = useId();

return (
<div className="container mx-auto max-w-6xl p-8">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">
Advanced Content Editor Demo
</h1>
<main aria-labelledby={workspaceTitleId} className="container mx-auto max-w-6xl p-8">
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<h1 id={workspaceTitleId} className="text-3xl font-bold text-gray-800 dark:text-white">
Post Editor
</h1>
<p id={editorHelpId} className="mt-2 text-sm text-gray-600 dark:text-gray-300">
Create accessible post content with keyboard-friendly formatting controls and a preview
mode for reviewing published output.
</p>
</div>
<button
type="button"
onClick={() => setIsPreviewMode((prev) => !prev)}
aria-pressed={isPreviewMode}
aria-controls={isPreviewMode ? previewPanelId : editorPanelId}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
isPreviewMode
? 'bg-blue-600 text-white hover:bg-blue-700'
Expand All @@ -37,15 +50,29 @@ export function EditorWorkspace() {
</button>
</div>

<p id={liveRegionId} className="sr-only" aria-live="polite">
{isPreviewMode ? 'Preview mode is active.' : 'Editor mode is active.'}
</p>

{isPreviewMode ? (
<div className="prose prose-lg max-w-none min-h-[calc(100vh-200px)] rounded-xl border border-gray-200 bg-white p-8 shadow-sm dark:prose-invert dark:border-gray-700 dark:bg-gray-800">
<section
id={previewPanelId}
aria-labelledby={workspaceTitleId}
aria-describedby={editorHelpId}
className="prose prose-lg max-w-none min-h-[calc(100vh-200px)] rounded-xl border border-gray-200 bg-white p-8 shadow-sm dark:prose-invert dark:border-gray-700 dark:bg-gray-800"
>
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(content) }} />
</div>
</section>
) : (
<div className="mb-8">
<section
id={editorPanelId}
aria-labelledby={workspaceTitleId}
aria-describedby={editorHelpId}
className="mb-8"
>
<RichContentEditor initialContent={content} onUpdate={setContent} />
</div>
</section>
)}
</div>
</main>
);
}
11 changes: 8 additions & 3 deletions src/components/editor/ContentTemplateLibrary.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useId } from 'react';
import { BookOpen, Layout, ListChecks, FileVideo } from 'lucide-react';
import { Editor } from '@tiptap/react';
import { TEMPLATES, insertTemplate } from '@/utils/editorUtils';
Expand All @@ -8,6 +8,8 @@ interface ContentTemplateLibraryProps {
}

export const ContentTemplateLibrary: React.FC<ContentTemplateLibraryProps> = ({ editor }) => {
const headingId = useId();

if (!editor) return null;

const getIcon = (id: string) => {
Expand All @@ -27,10 +29,13 @@ export const ContentTemplateLibrary: React.FC<ContentTemplateLibraryProps> = ({

return (
<aside
aria-label="Content templates"
aria-labelledby={headingId}
className="p-4 bg-gray-50 dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 h-full w-64 hidden lg:block"
>
<h3 className="font-semibold text-sm text-gray-500 uppercase mb-4 tracking-wider">
<h3
id={headingId}
className="font-semibold text-sm text-gray-500 uppercase mb-4 tracking-wider"
>
Templates
</h3>
<div className="space-y-2">
Expand Down
14 changes: 9 additions & 5 deletions src/components/editor/MediaEmbedder.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useId, useState } from 'react';
import { Image as ImageIcon, Youtube as YoutubeIcon } from 'lucide-react';
import { sanitizeUrl } from '@/utils/sanitize';

Expand All @@ -12,8 +12,10 @@ export const MediaEmbedder: React.FC<MediaEmbedderProps> = ({ onAddImage, onAddY
const [url, setUrl] = useState('');
const [type, setType] = useState<'image' | 'youtube'>('image');
const [urlError, setUrlError] = useState('');
const dialogTitleId = 'media-embedder-title';
const errorId = 'media-embedder-error';
const id = useId();
const dialogTitleId = `${id}-title`;
const errorId = `${id}-error`;
const inputId = `${id}-url`;

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
Expand All @@ -36,6 +38,7 @@ export const MediaEmbedder: React.FC<MediaEmbedderProps> = ({ onAddImage, onAddY
return (
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setType('image');
setIsOpen(true);
Expand All @@ -47,6 +50,7 @@ export const MediaEmbedder: React.FC<MediaEmbedderProps> = ({ onAddImage, onAddY
<ImageIcon className="w-5 h-5" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => {
setType('youtube');
setIsOpen(true);
Expand All @@ -73,11 +77,11 @@ export const MediaEmbedder: React.FC<MediaEmbedderProps> = ({ onAddImage, onAddY
Add {type === 'image' ? 'Image' : 'YouTube Video'}
</h3>
<form onSubmit={handleSubmit}>
<label htmlFor="media-url" className="sr-only">
<label htmlFor={inputId} className="sr-only">
{type === 'image' ? 'Image URL' : 'YouTube video URL'}
</label>
<input
id="media-url"
id={inputId}
type="url"
value={url}
onChange={(e) => {
Expand Down
31 changes: 24 additions & 7 deletions src/components/editor/RichContentEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useId } from 'react';
import { EditorContent } from '@tiptap/react';
import {
Bold,
Expand Down Expand Up @@ -27,8 +27,15 @@ export const RichContentEditor: React.FC<RichContentEditorProps> = ({
initialContent,
onUpdate,
}) => {
const editorTitleId = useId();
const editorDescriptionId = useId();
const toolbarId = useId();
const editorRegionId = useId();

const { editor, addImage, addYoutubeVideo } = useContentEditor({
initialContent,
ariaLabelledBy: editorTitleId,
ariaDescribedBy: `${editorDescriptionId} ${toolbarId}`,
onUpdate,
});

Expand All @@ -50,6 +57,7 @@ export const RichContentEditor: React.FC<RichContentEditorProps> = ({
title?: string;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={isActive}
Expand All @@ -64,12 +72,24 @@ export const RichContentEditor: React.FC<RichContentEditorProps> = ({
);

return (
<div className="flex h-[calc(100vh-100px)] bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<section
aria-labelledby={editorTitleId}
className="flex h-[calc(100vh-100px)] rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden"
>
<div className="flex flex-col flex-1 w-full min-w-0">
<div className="sr-only">
<h2 id={editorTitleId}>Post editor</h2>
<p id={editorDescriptionId}>
Use the formatting toolbar before the editor to style post content. The editor supports
multiline text, headings, lists, quotes, code blocks, images, and YouTube embeds.
</p>
</div>
{/* Toolbar */}
<div
id={toolbarId}
role="toolbar"
aria-label="Text formatting"
aria-controls={editorRegionId}
className="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 overflow-x-auto"
>
<div className="flex items-center gap-1">
Expand Down Expand Up @@ -164,16 +184,13 @@ export const RichContentEditor: React.FC<RichContentEditorProps> = ({
</div>

{/* Editor Content */}
<div
className="flex-1 overflow-y-auto bg-white dark:bg-gray-800"
aria-label="Post content editor"
>
<div id={editorRegionId} className="flex-1 overflow-y-auto bg-white dark:bg-gray-800">
<EditorContent editor={editor} className="h-full p-8" />
</div>
</div>

{/* Sidebar - Template Library */}
<ContentTemplateLibrary editor={editor} />
</div>
</section>
);
};
26 changes: 21 additions & 5 deletions src/hooks/useContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,31 @@ import { sanitizeHtml, sanitizeUrl } from '@/utils/sanitize';
interface UseContentEditorProps {
initialContent?: string;
placeholder?: string;
ariaLabel?: string;
ariaLabelledBy?: string;
ariaDescribedBy?: string;
onUpdate?: (content: string) => void;
}

export const useContentEditor = ({
initialContent = '',
placeholder = 'Start writing your content...',
ariaLabel = 'Post content editor',
ariaLabelledBy,
ariaDescribedBy,
onUpdate,
}: UseContentEditorProps) => {
const editorAttributes: Record<string, string> = {
role: 'textbox',
'aria-multiline': 'true',
'aria-placeholder': placeholder,
spellcheck: 'true',
class:
'prose prose-sm sm:prose lg:prose-lg xl:prose-xl mx-auto min-h-[300px] p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40',
...(ariaLabelledBy ? { 'aria-labelledby': ariaLabelledBy } : { 'aria-label': ariaLabel }),
...(ariaDescribedBy ? { 'aria-describedby': ariaDescribedBy } : {}),
};

const editor = useEditor(
{
extensions: [
Expand All @@ -32,6 +49,8 @@ export const useContentEditor = ({
}),
Placeholder.configure({
placeholder,
emptyEditorClass:
'before:content-[attr(data-placeholder)] before:text-gray-400 before:dark:text-gray-500 before:float-left before:h-0 before:pointer-events-none',
}),
],
immediatelyRender: false,
Expand All @@ -44,13 +63,10 @@ export const useContentEditor = ({
},
// Ensure responsiveness and consistency
editorProps: {
attributes: {
class:
'prose prose-sm sm:prose lg:prose-lg xl:prose-xl mx-auto focus:outline-none min-h-[300px] p-4',
},
attributes: editorAttributes,
},
},
[initialContent, placeholder, onUpdate],
[initialContent, placeholder, ariaLabel, ariaLabelledBy, ariaDescribedBy, onUpdate],
); // Added dependency array

const addImage = useCallback(
Expand Down
Loading