diff --git a/__tests__/unit/writing-parse.test.ts b/__tests__/unit/writing-parse.test.ts new file mode 100644 index 000000000..03eca702d --- /dev/null +++ b/__tests__/unit/writing-parse.test.ts @@ -0,0 +1,30 @@ +/** + * parseJsonLoose — the defensive parser between raw model output and the writing + * engine. Model JSON is unpredictable (code fences, leading prose), so this must + * be forgiving without ever throwing. + */ +import { parseJsonLoose } from '@/services/cat/platform-llm'; + +describe('parseJsonLoose', () => { + it('parses clean JSON objects and arrays', () => { + expect(parseJsonLoose('{"a":1}')).toEqual({ a: 1 }); + expect(parseJsonLoose('[1,2,3]')).toEqual([1, 2, 3]); + }); + + it('strips ```json fences', () => { + expect(parseJsonLoose('```json\n{"topics":[]}\n```')).toEqual({ topics: [] }); + expect(parseJsonLoose('```\n{"x":true}\n```')).toEqual({ x: true }); + }); + + it('recovers JSON embedded in prose', () => { + const raw = 'Sure! Here are your topics: {"topics":[{"title":"Hi"}]} Hope that helps.'; + expect(parseJsonLoose<{ topics: unknown[] }>(raw)?.topics).toHaveLength(1); + }); + + it('returns null (never throws) on unparseable or empty input', () => { + expect(parseJsonLoose(null)).toBeNull(); + expect(parseJsonLoose('')).toBeNull(); + expect(parseJsonLoose('not json at all')).toBeNull(); + expect(parseJsonLoose('{ broken ')).toBeNull(); + }); +}); diff --git a/src/app/(public)/articles/[slug]/ReadingProgress.tsx b/src/app/(public)/articles/[slug]/ReadingProgress.tsx new file mode 100644 index 000000000..4fde383f3 --- /dev/null +++ b/src/app/(public)/articles/[slug]/ReadingProgress.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * A thin top-of-page reading-progress bar. Tasteful, not gamified — a single + * accent hairline that tracks scroll depth, the standard long-form affordance. + */ +export default function ReadingProgress() { + const [progress, setProgress] = useState(0); + + useEffect(() => { + let frame = 0; + const update = () => { + frame = 0; + const doc = document.documentElement; + const scrollable = doc.scrollHeight - doc.clientHeight; + setProgress(scrollable > 0 ? Math.min(1, doc.scrollTop / scrollable) : 0); + }; + const onScroll = () => { + if (!frame) { + frame = requestAnimationFrame(update); + } + }; + update(); + window.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('resize', onScroll); + return () => { + window.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', onScroll); + if (frame) { + cancelAnimationFrame(frame); + } + }; + }, []); + + return ( +
+
+
+ ); +} diff --git a/src/app/(public)/articles/[slug]/ShareButton.tsx b/src/app/(public)/articles/[slug]/ShareButton.tsx new file mode 100644 index 000000000..33fbc7a0a --- /dev/null +++ b/src/app/(public)/articles/[slug]/ShareButton.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useState } from 'react'; +import { Check, Share2 } from 'lucide-react'; + +/** + * Share affordance for an article — native share sheet where available, else + * copy-to-clipboard with a brief confirmation. No third-party share widgets. + */ +export default function ShareButton({ title, url }: { title: string; url: string }) { + const [copied, setCopied] = useState(false); + + async function share() { + if (typeof navigator !== 'undefined' && navigator.share) { + try { + await navigator.share({ title, url }); + return; + } catch { + /* user dismissed — fall through to copy */ + } + } + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + /* clipboard blocked — no-op */ + } + } + + return ( + + ); +} diff --git a/src/app/(public)/articles/[slug]/page.tsx b/src/app/(public)/articles/[slug]/page.tsx index 5351b2309..ef0e75272 100644 --- a/src/app/(public)/articles/[slug]/page.tsx +++ b/src/app/(public)/articles/[slug]/page.tsx @@ -8,6 +8,8 @@ import { ROUTES } from '@/config/routes'; import { JsonLdScript } from '@/lib/seo/structured-data'; import { APP_NAME, SITE_URL } from '@/config/brand'; import ArticleMarkdown from './ArticleMarkdown'; +import ReadingProgress from './ReadingProgress'; +import ShareButton from './ShareButton'; interface PageProps { params: Promise<{ slug: string }>; @@ -75,10 +77,12 @@ export default async function ArticlePage({ params }: PageProps) { }; const authorHref = profileHref(article.author.username, article.author.id); + const shareUrl = `${SITE_URL}/articles/${article.slug}`; return ( <> {article.visibility === 'public' && } +
+ + {/* Footer: author card + share + write-your-own CTA */} +
diff --git a/src/app/(public)/articles/new/ArticleComposer.tsx b/src/app/(public)/articles/new/ArticleComposer.tsx index eaa5159c9..c9850fbe5 100644 --- a/src/app/(public)/articles/new/ArticleComposer.tsx +++ b/src/app/(public)/articles/new/ArticleComposer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { ArrowLeft, Eye, PenLine } from 'lucide-react'; @@ -13,7 +13,21 @@ import { ARTICLE_COPY, ARTICLE_LIMITS, estimateReadingTime } from '@/config/arti import { ROUTES } from '@/config/routes'; import { publishArticle } from '@/services/articles/create'; import type { TimelineVisibility } from '@/types/timeline'; +import type { ArticleDraft } from '@/services/cat/writing-types'; import ArticleMarkdown from '../[slug]/ArticleMarkdown'; +import MarkdownToolbar from '@/components/articles/MarkdownToolbar'; +import { useMarkdownTextarea } from '@/components/articles/useMarkdownTextarea'; +import AiWriterPanel from '@/components/articles/AiWriterPanel'; + +const DRAFT_KEY = 'oc:draft:article'; + +interface DraftShape { + title: string; + excerpt: string; + coverImage: string; + body: string; + visibility: TimelineVisibility; +} export default function ArticleComposer({ user }: { user: { id: string } }) { const router = useRouter(); @@ -25,9 +39,82 @@ export default function ArticleComposer({ user }: { user: { id: string } }) { const [tab, setTab] = useState<'write' | 'preview'>('write'); const [publishing, setPublishing] = useState(false); const [error, setError] = useState(null); + const [restored, setRestored] = useState(false); + + const bodyRef = useRef(null); + const md = useMarkdownTextarea(bodyRef, body, setBody); + + // Restore an in-progress draft once on mount. + useEffect(() => { + try { + const raw = localStorage.getItem(DRAFT_KEY); + if (!raw) { + return; + } + const d = JSON.parse(raw) as Partial; + if (d.title || d.body) { + setTitle(d.title ?? ''); + setExcerpt(d.excerpt ?? ''); + setCoverImage(d.coverImage ?? ''); + setBody(d.body ?? ''); + if (d.visibility) { + setVisibility(d.visibility); + } + setRestored(true); + } + } catch { + /* ignore corrupt draft */ + } + }, []); + + // Autosave (debounced) whenever content changes. + useEffect(() => { + if (!title && !body && !excerpt && !coverImage) { + return; + } + const id = setTimeout(() => { + try { + localStorage.setItem( + DRAFT_KEY, + JSON.stringify({ title, excerpt, coverImage, body, visibility } satisfies DraftShape) + ); + } catch { + /* storage full / disabled — non-fatal */ + } + }, 600); + return () => clearTimeout(id); + }, [title, excerpt, coverImage, body, visibility]); + const wordCount = body.trim() ? body.trim().split(/\s+/).length : 0; + const readingTime = wordCount ? estimateReadingTime(body) : 0; const canPublish = title.trim().length > 0 && body.trim().length > 0 && !publishing; - const readingTime = body.trim() ? estimateReadingTime(body) : 0; + + function applyDraft(draft: ArticleDraft) { + setTitle(draft.title); + if (draft.excerpt) { + setExcerpt(draft.excerpt); + } + setBody(draft.body); + setTab('write'); + setRestored(false); + } + + function handleBodyKeyDown(e: React.KeyboardEvent) { + if (!(e.metaKey || e.ctrlKey)) { + return; + } + const k = e.key.toLowerCase(); + if (k === 'b') { + e.preventDefault(); + md.wrap('**', '**', 'bold'); + } else if (k === 'i') { + e.preventDefault(); + md.wrap('*', '*', 'italic'); + } else if (k === 'k') { + e.preventDefault(); + md.insertLink(); + } + } async function handlePublish() { if (!canPublish) { @@ -47,6 +134,11 @@ export default function ArticleComposer({ user }: { user: { id: string } }) { setPublishing(false); return; } + try { + localStorage.removeItem(DRAFT_KEY); + } catch { + /* ignore */ + } router.push(ROUTES.ARTICLE(result.slug)); } @@ -61,15 +153,25 @@ export default function ArticleComposer({ user }: { user: { id: string } }) { {ARTICLE_COPY.reader.back} -
+

{ARTICLE_COPY.new.heading}

{ARTICLE_COPY.new.subheading}

+
+ +
+ + {restored && ( +

+ Restored your saved draft. +

+ )} + {/* Write / Preview tabs */} -
+
- {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" /> -