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
30 changes: 30 additions & 0 deletions __tests__/unit/writing-parse.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
45 changes: 45 additions & 0 deletions src/app/(public)/articles/[slug]/ReadingProgress.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed inset-x-0 top-0 z-50 h-0.5 bg-transparent" aria-hidden>
<div
className="h-full bg-accent-warm transition-[width] duration-150 ease-out"
style={{ width: `${progress * 100}%` }}
/>
</div>
);
}
41 changes: 41 additions & 0 deletions src/app/(public)/articles/[slug]/ShareButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={share}
className="inline-flex items-center gap-1.5 rounded-md border border-default px-3 py-1.5 text-sm font-medium text-fg-secondary transition-colors hover:bg-surface-raised hover:text-fg-primary"
>
{copied ? <Check className="h-4 w-4 text-status-positive" /> : <Share2 className="h-4 w-4" />}
{copied ? 'Link copied' : 'Share'}
</button>
);
}
44 changes: 44 additions & 0 deletions src/app/(public)/articles/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -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' && <JsonLdScript data={jsonLd} />}
<ReadingProgress />
<div className="min-h-screen bg-surface-page pt-20 pb-24 text-fg-primary">
<article className="mx-auto w-full max-w-[680px] px-5">
<Link
Expand Down Expand Up @@ -151,6 +155,46 @@ export default async function ArticlePage({ params }: PageProps) {
<div className="[&>*:first-child]:mt-0">
<ArticleMarkdown body={article.body} />
</div>

{/* Footer: author card + share + write-your-own CTA */}
<footer className="mt-14 border-t border-subtle pt-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<Link
href={authorHref}
className="flex items-center gap-3 transition-opacity hover:opacity-80"
>
{article.author.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.author.avatarUrl}
alt=""
className="h-11 w-11 rounded-full border border-subtle object-cover"
/>
) : (
<span className="flex h-11 w-11 items-center justify-center rounded-full border border-subtle bg-surface-raised text-sm font-semibold text-fg-secondary">
{article.author.name.slice(0, 1).toUpperCase()}
</span>
)}
<span>
<span className="block text-xs text-fg-tertiary">Written by</span>
<span className="block font-semibold text-fg-primary">{article.author.name}</span>
</span>
</Link>
<ShareButton title={article.title} url={shareUrl} />
</div>

<div className="mt-8 flex flex-col items-start gap-3 rounded-xl border border-subtle bg-surface-raised/25 px-5 py-5 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-fg-secondary">
Have something to say? Publishing on OrangeCat is free.
</p>
<Link
href={ROUTES.ARTICLES_NEW}
className="inline-flex flex-shrink-0 items-center gap-1.5 rounded-md bg-accent-warm px-3.5 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-warm-hover"
>
Write your own
</Link>
</div>
</footer>
</article>
</div>
</>
Expand Down
140 changes: 125 additions & 15 deletions src/app/(public)/articles/new/ArticleComposer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,7 +13,21 @@
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();
Expand All @@ -25,9 +39,82 @@
const [tab, setTab] = useState<'write' | 'preview'>('write');
const [publishing, setPublishing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [restored, setRestored] = useState(false);

const bodyRef = useRef<HTMLTextAreaElement>(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<DraftShape>;
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<HTMLTextAreaElement>) {
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) {
Expand All @@ -42,11 +129,16 @@
coverImage: coverImage || undefined,
visibility,
});
if (!result.success) {

Check failure

Code scanning / CodeQL

DOM text reinterpreted as HTML High

DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
setError(result.error);
setPublishing(false);
return;
}
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
/* ignore */
}
router.push(ROUTES.ARTICLE(result.slug));
}

Expand All @@ -61,15 +153,25 @@
{ARTICLE_COPY.reader.back}
</Link>

<header className="mb-6">
<header className="mb-5">
<h1 className="text-2xl font-semibold tracking-display text-fg-primary">
{ARTICLE_COPY.new.heading}
</h1>
<p className="mt-1.5 text-sm text-fg-secondary">{ARTICLE_COPY.new.subheading}</p>
</header>

<div className="mb-5">
<AiWriterPanel title={title} onApplyDraft={applyDraft} disabled={publishing} />
</div>

{restored && (
<p className="mb-4 rounded-md border border-subtle bg-surface-raised/30 px-3 py-2 text-xs text-fg-secondary">
Restored your saved draft.
</p>
)}

{/* Write / Preview tabs */}
<div className="mb-4 flex items-center gap-2">
<div className="mb-3 flex items-center gap-2">
<button
type="button"
onClick={() => setTab('write')}
Expand All @@ -84,8 +186,11 @@
>
<Eye className="h-4 w-4" /> Preview
</button>
{readingTime > 0 && (
<span className="ml-auto text-xs text-fg-tertiary">{readingTime} min read</span>
{wordCount > 0 && (
<span className="ml-auto text-xs text-fg-tertiary">
{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'} · {readingTime} min
read
</span>
)}
</div>

Expand Down Expand Up @@ -114,15 +219,20 @@
type="url"
aria-label="Cover image URL"
/>
<Textarea
value={body}
onChange={e => setBody(e.target.value)}
placeholder={ARTICLE_COPY.new.bodyPlaceholder}
rows={18}
maxLength={ARTICLE_LIMITS.body}
aria-label="Article body"
className="font-mono text-sm leading-6"
/>
<div>
<MarkdownToolbar actions={md} disabled={publishing} />
<Textarea
ref={bodyRef}
value={body}
onChange={e => setBody(e.target.value)}
onKeyDown={handleBodyKeyDown}
placeholder={ARTICLE_COPY.new.bodyPlaceholder}
rows={18}
maxLength={ARTICLE_LIMITS.body}
aria-label="Article body"
className="rounded-t-none font-mono text-sm leading-6"
/>
</div>
</div>
) : (
<div className="rounded-lg border border-subtle bg-surface-page p-6">
Expand Down
Loading
Loading