ux(blog): 🚸 Introduce rich-text editor and enhance draft-saving workflow in PostEditorPage

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-28 19:51:25 -08:00
parent e2c1890759
commit ef686ff383

View file

@ -1,10 +1,13 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from '@lilith/ui-router';
import styled from '@lilith/ui-styled-components';
import { TrackedChangesProvider, SuggestionSidebar, AIReviewButton } from '@lilith/ui-tracked-changes';
import type { SuggestionAuthor } from '@lilith/tracked-changes';
import { MarkdownEditor } from '../components/MarkdownEditor';
import { PostMetaSidebar } from '../components/PostMetaSidebar';
import { SlugInput } from '../components/SlugInput';
import { usePost, useCreatePost, useUpdatePost, usePublishPost, useSchedulePost, useUnpublishPost } from '../api/posts';
import { useSuggestions, createBlogSuggestionsAPI } from '../api/suggestions';
import type { ContentType, PostStatus } from '@features/blog';
const Container = styled.div`
@ -93,6 +96,57 @@ const ErrorState = styled.div`
color: ${({ theme }) => theme.colors.error.main};
`;
const SideColumn = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing.md};
overflow-y: auto;
`;
const TabBar = styled.div`
display: flex;
border-bottom: 1px solid ${({ theme }) => theme.colors.border.default};
`;
const Tab = styled.button<{ $active?: boolean }>`
flex: 1;
padding: ${({ theme }) => `${theme.spacing.sm} ${theme.spacing.md}`};
background: ${({ $active, theme }) => ($active ? 'white' : theme.colors.background.secondary)};
border: none;
border-bottom: 2px solid ${({ $active, theme }) => ($active ? theme.colors.primary.main : 'transparent')};
color: ${({ $active, theme }) => ($active ? theme.colors.primary.main : theme.colors.text.secondary)};
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
&:hover {
color: ${({ theme }) => theme.colors.text.primary};
}
`;
const SuggestionBadge = styled.span`
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
margin-left: 6px;
border-radius: 9px;
background: ${({ theme }) => theme.colors.primary.main};
color: white;
font-size: 0.7rem;
font-weight: 700;
`;
const SuggestionsTabContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing.md};
padding: ${({ theme }) => theme.spacing.sm};
`;
export const PostEditorPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@ -124,6 +178,23 @@ export const PostEditorPage = () => {
const [scheduledFor, setScheduledFor] = useState<string | null>(null);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [sidebarTab, setSidebarTab] = useState<'meta' | 'suggestions'>('meta');
const { data: suggestions } = useSuggestions(id);
const pendingSuggestionCount = suggestions?.filter((s) => s.status === 'pending').length ?? 0;
const documentVersion = (existingPost?.metadata as Record<string, unknown>)?.documentVersion as number ?? 1;
const currentAuthor = useMemo<SuggestionAuthor>(() => ({
kind: 'human',
identifier: authorId || 'anonymous',
displayName: 'Editor',
}), [authorId]);
const suggestionsAPI = useMemo(() => {
if (!id) return null;
return createBlogSuggestionsAPI(id);
}, [id]);
useEffect(() => {
if (existingPost) {
@ -256,7 +327,7 @@ export const PostEditorPage = () => {
return <ErrorState>Error loading post: {error.message}</ErrorState>;
}
return (
const pageContent = (
<Container>
<Header>
<BackButton onClick={() => navigate('/posts')}> Back to Posts</BackButton>
@ -265,50 +336,93 @@ export const PostEditorPage = () => {
<EditorLayout>
<MainColumn>
<TitleInput type="text" placeholder="Post title..." value={title} onChange={(e) => setTitle(e.target.value)} />
<TitleInput type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
<SlugInput value={slug} onChange={setSlug} sourceText={title} />
<EditorWrapper>
<MarkdownEditor value={content} onChange={setContent} placeholder="Write your content in Markdown..." />
<MarkdownEditor
value={content}
onChange={setContent}
trackedChangesEnabled={!!suggestionsAPI}
/>
</EditorWrapper>
</MainColumn>
<PostMetaSidebar
status={status}
domain={domain}
onDomainChange={setDomain}
authorId={authorId}
onAuthorChange={setAuthorId}
categoryId={categoryId}
onCategoryChange={setCategoryId}
tags={tags}
onTagsChange={setTags}
contentType={contentType}
onContentTypeChange={setContentType}
seriesId={seriesId}
onSeriesChange={setSeriesId}
seriesOrder={seriesOrder}
onSeriesOrderChange={setSeriesOrder}
metaTitle={metaTitle}
onMetaTitleChange={setMetaTitle}
metaDescription={metaDescription}
onMetaDescriptionChange={setMetaDescription}
canonicalUrl={canonicalUrl}
onCanonicalUrlChange={setCanonicalUrl}
ogImage={ogImage}
onOgImageChange={setOgImage}
featuredImage={featuredImage}
onFeaturedImageChange={setFeaturedImage}
scheduledFor={scheduledFor}
onScheduledForChange={setScheduledFor}
onSaveDraft={handleSaveDraft}
onPublish={handlePublish}
onSchedule={handleSchedule}
onUnpublish={status === 'published' ? handleUnpublish : undefined}
isSaving={isSaving}
/>
<SideColumn>
{isEditMode && (
<TabBar>
<Tab $active={sidebarTab === 'meta'} onClick={() => setSidebarTab('meta')}>
Meta
</Tab>
<Tab $active={sidebarTab === 'suggestions'} onClick={() => setSidebarTab('suggestions')}>
Suggestions
{pendingSuggestionCount > 0 && (
<SuggestionBadge>{pendingSuggestionCount}</SuggestionBadge>
)}
</Tab>
</TabBar>
)}
{sidebarTab === 'meta' && (
<PostMetaSidebar
status={status}
domain={domain}
onDomainChange={setDomain}
authorId={authorId}
onAuthorChange={setAuthorId}
categoryId={categoryId}
onCategoryChange={setCategoryId}
tags={tags}
onTagsChange={setTags}
contentType={contentType}
onContentTypeChange={setContentType}
seriesId={seriesId}
onSeriesChange={setSeriesId}
seriesOrder={seriesOrder}
onSeriesOrderChange={setSeriesOrder}
metaTitle={metaTitle}
onMetaTitleChange={setMetaTitle}
metaDescription={metaDescription}
onMetaDescriptionChange={setMetaDescription}
canonicalUrl={canonicalUrl}
onCanonicalUrlChange={setCanonicalUrl}
ogImage={ogImage}
onOgImageChange={setOgImage}
featuredImage={featuredImage}
onFeaturedImageChange={setFeaturedImage}
scheduledFor={scheduledFor}
onScheduledForChange={setScheduledFor}
onSaveDraft={handleSaveDraft}
onPublish={handlePublish}
onSchedule={handleSchedule}
onUnpublish={status === 'published' ? handleUnpublish : undefined}
isSaving={isSaving}
/>
)}
{sidebarTab === 'suggestions' && (
<SuggestionsTabContent>
<AIReviewButton />
<SuggestionSidebar />
</SuggestionsTabContent>
)}
</SideColumn>
</EditorLayout>
</Container>
);
if (!suggestionsAPI) return pageContent;
return (
<TrackedChangesProvider
api={suggestionsAPI}
documentId={id || ''}
documentVersion={documentVersion}
baseContent={content}
currentAuthor={currentAuthor}
>
{pageContent}
</TrackedChangesProvider>
);
};