+
- {readingTime > 0 && (
- {readingTime} min read
+ {wordCount > 0 && (
+
+ {wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'} · {readingTime} min
+ read
+
)}
@@ -114,15 +219,20 @@ export default function ArticleComposer({ user }: { user: { id: string } }) {
type="url"
aria-label="Cover image URL"
/>
-
) : (
diff --git a/src/app/api/ai/writing/draft/route.ts b/src/app/api/ai/writing/draft/route.ts
new file mode 100644
index 000000000..e7e7eef7a
--- /dev/null
+++ b/src/app/api/ai/writing/draft/route.ts
@@ -0,0 +1,52 @@
+/**
+ * POST /api/ai/writing/draft — AI draft of a post or a full article, in the
+ * user's voice and grounded in their context. Thin wrapper over the
+ * writing-engine; auth + write-tier rate limit.
+ */
+
+import type { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { withAuth, type AuthenticatedRequest } from '@/lib/api/withAuth';
+import { createRateLimitResponse, rateLimitWriteAsync } from '@/lib/rate-limit';
+import { apiBadRequest, apiError, apiInternalError, apiSuccess } from '@/lib/api/standardResponse';
+import { draftArticle, draftPost } from '@/services/cat/writing-engine';
+import { logger } from '@/utils/logger';
+
+const bodySchema = z.object({
+ mode: z.enum(['post', 'article']),
+ topic: z.string().trim().max(300).optional(),
+ focus: z.string().trim().max(200).optional(),
+});
+
+export const POST = withAuth(async (request: AuthenticatedRequest) => {
+ const { user, supabase } = request;
+ try {
+ const rl = await rateLimitWriteAsync(user.id);
+ if (!rl.success) {
+ return createRateLimitResponse(rl) as NextResponse;
+ }
+
+ const parsed = bodySchema.safeParse(await (request as NextRequest).json().catch(() => ({})));
+ if (!parsed.success) {
+ return apiBadRequest('Invalid request', parsed.error.flatten());
+ }
+
+ const { mode, topic, focus } = parsed.data;
+ const draft =
+ mode === 'article'
+ ? await draftArticle(supabase, user.id, { topic, focus })
+ : await draftPost(supabase, user.id, { topic, focus });
+
+ if (!draft) {
+ return apiError(
+ 'The AI writer is busy right now. Try again in a moment, or add your own free Groq key in Settings → AI.',
+ 'AI_UNAVAILABLE',
+ 503
+ ) as NextResponse;
+ }
+ return apiSuccess({ mode, draft }) as NextResponse;
+ } catch (error) {
+ logger.error('writing/draft failed', error, 'WritingAPI');
+ return apiInternalError('Could not draft that right now. Please try again.');
+ }
+});
diff --git a/src/app/api/ai/writing/topics/route.ts b/src/app/api/ai/writing/topics/route.ts
new file mode 100644
index 000000000..36db495b9
--- /dev/null
+++ b/src/app/api/ai/writing/topics/route.ts
@@ -0,0 +1,40 @@
+/**
+ * POST /api/ai/writing/topics — AI-suggested writing topics grounded in the
+ * user's own context (profile, entities, memories, past posts). Thin wrapper
+ * over the writing-engine; auth + write-tier rate limit.
+ */
+
+import type { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { withAuth, type AuthenticatedRequest } from '@/lib/api/withAuth';
+import { createRateLimitResponse, rateLimitWriteAsync } from '@/lib/rate-limit';
+import { apiBadRequest, apiInternalError, apiSuccess } from '@/lib/api/standardResponse';
+import { suggestTopics } from '@/services/cat/writing-engine';
+import { logger } from '@/utils/logger';
+
+const bodySchema = z.object({
+ count: z.number().int().min(1).max(8).optional(),
+ kind: z.enum(['post', 'article', 'any']).optional(),
+ focus: z.string().trim().max(200).optional(),
+});
+
+export const POST = withAuth(async (request: AuthenticatedRequest) => {
+ const { user, supabase } = request;
+ try {
+ const rl = await rateLimitWriteAsync(user.id);
+ if (!rl.success) {
+ return createRateLimitResponse(rl) as NextResponse;
+ }
+
+ const parsed = bodySchema.safeParse(await (request as NextRequest).json().catch(() => ({})));
+ if (!parsed.success) {
+ return apiBadRequest('Invalid request', parsed.error.flatten());
+ }
+
+ const topics = await suggestTopics(supabase, user.id, parsed.data);
+ return apiSuccess({ topics }) as NextResponse;
+ } catch (error) {
+ logger.error('writing/topics failed', error, 'WritingAPI');
+ return apiInternalError('Could not suggest topics right now. Please try again.');
+ }
+});
diff --git a/src/components/articles/AiWriterPanel.tsx b/src/components/articles/AiWriterPanel.tsx
new file mode 100644
index 000000000..83f4c1760
--- /dev/null
+++ b/src/components/articles/AiWriterPanel.tsx
@@ -0,0 +1,132 @@
+'use client';
+
+import { useState } from 'react';
+import { Sparkles, Loader2, PenLine, Lightbulb, ArrowRight } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { fetchArticleDraft, fetchWritingTopics } from '@/services/articles/ai-client';
+import type { ArticleDraft, ProposedTopic } from '@/services/cat/writing-types';
+
+/**
+ * One-click AI writing for the article composer. "Write a full draft" fills the
+ * whole editor; "Suggest topics" grounds ideas in the user's own interests and
+ * drafts the one they pick. Fails soft — errors surface inline, never block.
+ */
+export default function AiWriterPanel({
+ title,
+ onApplyDraft,
+ disabled,
+}: {
+ title: string;
+ onApplyDraft: (draft: ArticleDraft) => void;
+ disabled?: boolean;
+}) {
+ const [busy, setBusy] = useState
(null);
+ const [topics, setTopics] = useState([]);
+ const [error, setError] = useState(null);
+
+ const anyBusy = busy !== null || disabled;
+
+ async function writeDraft(topic?: string) {
+ setBusy(topic ?? 'draft');
+ setError(null);
+ try {
+ const draft = await fetchArticleDraft({ topic: topic || title.trim() || undefined });
+ onApplyDraft(draft);
+ setTopics([]);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Could not draft that. Please try again.');
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ async function loadTopics() {
+ setBusy('topics');
+ setError(null);
+ try {
+ setTopics(await fetchWritingTopics({ kind: 'article', count: 5 }));
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Could not suggest topics. Please try again.');
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
Write with AI
+
+ Grounded in what you care about and what you've written before.
+
+
+
+
+
+
+
+ {topics.length > 0 && (
+
+ {topics.map((t, i) => (
+ -
+
+
+ ))}
+
+ )}
+
+ {error &&
{error}
}
+
+
+
+ );
+}
diff --git a/src/components/articles/MarkdownToolbar.tsx b/src/components/articles/MarkdownToolbar.tsx
new file mode 100644
index 000000000..94bbbbaa2
--- /dev/null
+++ b/src/components/articles/MarkdownToolbar.tsx
@@ -0,0 +1,134 @@
+'use client';
+
+import {
+ Bold,
+ Italic,
+ Heading2,
+ Heading3,
+ List,
+ ListOrdered,
+ Quote,
+ Link2,
+ Code,
+} from 'lucide-react';
+import type { MarkdownActions } from './useMarkdownTextarea';
+import { cn } from '@/lib/utils';
+
+/**
+ * Formatting toolbar for the markdown article body. Purely drives
+ * {@link MarkdownActions} — the body stays plain markdown (SSOT), rendered
+ * safely by ArticleMarkdown. Design-token styling only.
+ */
+export default function MarkdownToolbar({
+ actions,
+ disabled,
+}: {
+ actions: MarkdownActions;
+ disabled?: boolean;
+}) {
+ const btn =
+ 'flex h-8 w-8 items-center justify-center rounded-md text-fg-secondary transition-colors hover:bg-surface-raised hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40';
+
+ const Divider = () => ;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/articles/useMarkdownTextarea.ts b/src/components/articles/useMarkdownTextarea.ts
new file mode 100644
index 000000000..bb61a8d52
--- /dev/null
+++ b/src/components/articles/useMarkdownTextarea.ts
@@ -0,0 +1,93 @@
+'use client';
+
+import { useCallback, type RefObject } from 'react';
+
+/**
+ * Selection-aware markdown editing for a controlled